JavaScript 108 views

Custom Scroll-Triggered Animation Effect

This snippet adds engaging animations to elements as they come into view while scrolling, enhancing user interaction.

By TWC Team • Feb 05, 2026

Code

/**
 * Custom Scroll-Triggered Animation Effect
 * Adds an `.animate` class to `.animate-on-scroll` elements when they enter the viewport; lightweight, reusable, and performant.
 * Usage: add `.animate-on-scroll` to elements; define CSS for `.animate` (and optional `.is-visible`) to trigger animations.
 */
(() => {
  'use strict';

  const SELECTOR = '.animate-on-scroll';
  const ANIMATE_CLASS = 'animate';
  const VISIBLE_CLASS = 'is-visible';

  // Debounce limits how often we run expensive work during rapid events (scroll/resize).
  const debounce = (fn, delay = 100) => {
    let timeoutId;
    return (...args) => {
      window.clearTimeout(timeoutId);
      timeoutId = window.setTimeout(() => fn(...args), delay);
    };
  };

  const safeQueryAll = (selector) => {
    try {
      return Array.from(document.querySelectorAll(selector));
    } catch (err) {
      console.error('[ScrollAnimate] Invalid selector:', selector, err);
      return [];
    }
  };

  const isInViewport = (el) => {
    const { top, bottom } = el.getBoundingClientRect();
    return top < window.innerHeight && bottom > 0;
  };

  const addAnimationClasses = (el) => {
    el.classList.add(VISIBLE_CLASS, ANIMATE_CLASS);
  };

  const initScrollTriggeredAnimations = async ({
    selector = SELECTOR,
    debounceMs = 100,
    once = true,
  } = {}) => {
    try {
      let elements = safeQueryAll(selector);
      if (!elements.length) return;

      // Prefer IntersectionObserver for performance; fall back to scroll handler where unsupported.
      if ('IntersectionObserver' in window) {
        const observer = new IntersectionObserver(
          (entries, obs) => {
            entries.forEach(({ isIntersecting, target }) => {
              if (!isIntersecting) return;
              addAnimationClasses(target);
              if (once) obs.unobserve(target);
            });
          },
          { root: null, threshold: 0.01 }
        );

        elements.forEach((el) => observer.observe(el));
        return;
      }

      const handleScrollAnimation = () => {
        elements.forEach((el) => {
          if (el.classList.contains(ANIMATE_CLASS) && once) return;
          if (isInViewport(el)) addAnimationClasses(el);
        });

        // If we're only animating once, we can stop checking elements already animated.
        if (once) elements = elements.filter((el) => !el.classList.contains(ANIMATE_CLASS));
      };

      const onScroll = debounce(handleScrollAnimation, debounceMs);
      window.addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll, { passive: true });

      handleScrollAnimation(); // Initial check on page load
    } catch (err) {
      console.error('[ScrollAnimate] Failed to initialize:', err);
    }
  };

  // Initialize after DOM is ready; async-safe and avoids global scope pollution.
  document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', () => void initScrollTriggeredAnimations())
    : void initScrollTriggeredAnimations();

  // Example usage:
  // <div class="animate-on-scroll">...</div>
  // CSS: .animate-on-scroll { opacity: 0; transform: translateY(16px); transition: 600ms ease; }
  //      .animate-on-scroll.animate { opacity: 1; transform: translateY(0); }
})();
Back to Snippets