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>
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>