Retrigger CSS Animations On Scroll
Scroll
CSS Animation
JS
CSS
- Give section an attribute of data-css-scroll
- When section scrolls into view, all children that have css animations will restart
- Animation retriggers each time we scroll down into the section.
- If animation should retrigger when scrolling back up into the section also, change attribute to data-css-scroll="retrigger-both"
- If animation should not retrigger when scrolling back into the section, change attribute to data-css-scroll="retrigger-none"
- The optional attribute for setting what percent of an element should be in view before animation is triggered is data-css-scroll-threshold="25%"
<style>
.reset-animations,
.reset-animations * {
animation-name: none !important;
animation-duration: 0s !important;
}
[data-css-scroll].animation-ready,
[data-css-scroll].animation-ready * {
animation-duration: 0s !important;
animation-direction: reverse !important;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", () => {
const targets = document.querySelectorAll("[data-css-scroll]");
if (!targets.length) return;
const thresholdGroups = new Map();
targets.forEach(el => {
const thresholdStr = el.dataset.cssScrollThreshold || "50%";
const threshold = parseFloat(thresholdStr) / 100;
if (!thresholdGroups.has(threshold)) thresholdGroups.set(threshold, []);
thresholdGroups.get(threshold).push(el);
});
const entryObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting && !entry.target.__pastThreshold && entry.target.__ioInit) {
entry.target.classList.add("animation-ready");
entryObserver.unobserve(entry.target);
}
}
}, { threshold: 0, rootMargin: "-1px" });
const exitObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
const el = entry.target;
const mode = el.dataset.cssScroll || "";
if (!entry.isIntersecting && mode !== "retrigger-none" && el.__ioInit) {
const isExitingTop = entry.boundingClientRect.bottom < entry.rootBounds.top;
if (mode === "retrigger-both" || !isExitingTop) el.classList.add("animation-ready");
}
}
}, { threshold: 0, rootMargin: "-1px" });
thresholdGroups.forEach((elements, threshold) => {
const io = new IntersectionObserver(entries => {
for (const entry of entries) {
const el = entry.target;
const mode = el.dataset.cssScroll || "";
if (!el.__ioInit) {
el.__ioInit = true;
if (entry.isIntersecting) el.__cssAnimTriggered = el.__pastThreshold = true;
continue;
}
if (!entry.isIntersecting) {
if (mode !== "retrigger-none") el.__cssAnimTriggered = false;
continue;
}
const isScrollingDown = entry.boundingClientRect.top >= 0 && entry.boundingClientRect.top < entry.rootBounds.bottom;
const shouldTrigger = mode === "retrigger-none" ? !el.__cssAnimTriggered : mode === "retrigger-both" ? !el.__cssAnimTriggered : !el.__cssAnimTriggered && isScrollingDown;
if (!shouldTrigger) continue;
el.__cssAnimTriggered = true;
if (!el.classList.contains("animation-ready")) continue;
el.classList.remove("animation-ready");
el.classList.add("reset-animations");
el.offsetHeight;
requestAnimationFrame(() => {
el.classList.remove("reset-animations");
if (mode === "retrigger-none") io.unobserve(el);
});
}
}, { threshold });
elements.forEach(el => {
io.observe(el);
entryObserver.observe(el);
exitObserver.observe(el);
});
});
});
</script>Preview Links
Custom JS
Enable custom code in preview or view on published site.
Enable custom code?
Last Updated: Dec 30, 2025
- Give section an attribute of data-css-scroll
- When section scrolls into view, all children that have css animations will restart
- Animation retriggers each time we scroll down into the section.
- If animation should retrigger when scrolling back up into the section also, change attribute to data-css-scroll="retrigger-both"
- If animation should not retrigger when scrolling back into the section, change attribute to data-css-scroll="retrigger-none"
- The optional attribute for setting what percent of an element should be in view before animation is triggered is data-css-scroll-threshold="25%"
<style>
.reset-animations,
.reset-animations * {
animation-name: none !important;
animation-duration: 0s !important;
}
[data-css-scroll].animation-ready,
[data-css-scroll].animation-ready * {
animation-duration: 0s !important;
animation-direction: reverse !important;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", () => {
const targets = document.querySelectorAll("[data-css-scroll]");
if (!targets.length) return;
const thresholdGroups = new Map();
targets.forEach(el => {
const thresholdStr = el.dataset.cssScrollThreshold || "50%";
const threshold = parseFloat(thresholdStr) / 100;
if (!thresholdGroups.has(threshold)) thresholdGroups.set(threshold, []);
thresholdGroups.get(threshold).push(el);
});
const entryObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting && !entry.target.__pastThreshold && entry.target.__ioInit) {
entry.target.classList.add("animation-ready");
entryObserver.unobserve(entry.target);
}
}
}, { threshold: 0, rootMargin: "-1px" });
const exitObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
const el = entry.target;
const mode = el.dataset.cssScroll || "";
if (!entry.isIntersecting && mode !== "retrigger-none" && el.__ioInit) {
const isExitingTop = entry.boundingClientRect.bottom < entry.rootBounds.top;
if (mode === "retrigger-both" || !isExitingTop) el.classList.add("animation-ready");
}
}
}, { threshold: 0, rootMargin: "-1px" });
thresholdGroups.forEach((elements, threshold) => {
const io = new IntersectionObserver(entries => {
for (const entry of entries) {
const el = entry.target;
const mode = el.dataset.cssScroll || "";
if (!el.__ioInit) {
el.__ioInit = true;
if (entry.isIntersecting) el.__cssAnimTriggered = el.__pastThreshold = true;
continue;
}
if (!entry.isIntersecting) {
if (mode !== "retrigger-none") el.__cssAnimTriggered = false;
continue;
}
const isScrollingDown = entry.boundingClientRect.top >= 0 && entry.boundingClientRect.top < entry.rootBounds.bottom;
const shouldTrigger = mode === "retrigger-none" ? !el.__cssAnimTriggered : mode === "retrigger-both" ? !el.__cssAnimTriggered : !el.__cssAnimTriggered && isScrollingDown;
if (!shouldTrigger) continue;
el.__cssAnimTriggered = true;
if (!el.classList.contains("animation-ready")) continue;
el.classList.remove("animation-ready");
el.classList.add("reset-animations");
el.offsetHeight;
requestAnimationFrame(() => {
el.classList.remove("reset-animations");
if (mode === "retrigger-none") io.unobserve(el);
});
}
}, { threshold });
elements.forEach(el => {
io.observe(el);
entryObserver.observe(el);
exitObserver.observe(el);
});
});
});
</script>











