Programming Intermediate 9 min

How to Animate Elements On Scroll

Scroll-triggered animations — where elements fade or slide in as they enter the viewport — are one of the most effective UI details you can add. Done right they are fast, accessible, and require almost no code. This guide covers the IntersectionObserver approach and the modern CSS-native alternative.

Step-by-step

  1. 1

    Mark Elements with data-animate

    Add a data-animate attribute to any element you want to animate. This keeps markup semantically clean and gives JavaScript a targeted selector that does not conflict with class-based styling.

    html
    <section data-animate>
      <h2>Section Title</h2>
      <p>Content that fades in on scroll.</p>
    </section>
    
    <div class="card" data-animate>...</div>
    <img src="photo.jpg" alt="..." data-animate>
  2. 2

    Set the Default Hidden State in CSS

    Set each [data-animate] element to its "before" state — invisible and slightly shifted down. The transition is defined here, so CSS handles all the animation work once the class changes.

    css
    [data-animate] {
      opacity: 0;
      transform: translateY(20px);
      transition: opacity 0.5s ease, transform 0.5s ease;
    }
  3. 3

    Define the Visible State with .is-in

    When an element has the .is-in class, it is fully visible and in its natural position. The transition defined on the base state plays automatically when this class is added.

    css
    [data-animate].is-in {
      opacity: 1;
      transform: translateY(0);
    }
  4. 4

    Use IntersectionObserver to Add the Class

    IntersectionObserver fires a callback when an element crosses a visibility threshold — no scroll event listener, no layout reads, no jank. Once the element is visible, unobserve it so it does not re-animate when the user scrolls back up.

    javascript
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            entry.target.classList.add('is-in');
            observer.unobserve(entry.target); // animate once, then stop watching
          }
        });
      },
      { threshold: 0.15 } // trigger when 15% of the element is visible
    );
    
    document.querySelectorAll('[data-animate]').forEach((el) => {
      observer.observe(el);
    });
  5. 5

    Respect prefers-reduced-motion

    Some users have motion sensitivity and have set their OS to reduce animations. Detect this preference and skip the animation entirely — make every element immediately visible instead of hiding and revealing it.

    javascript
    // Check the preference before setting up any animations
    const prefersReduced = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    ).matches;
    
    if (prefersReduced) {
      // Make all elements immediately visible — no animation
      document.querySelectorAll('[data-animate]').forEach((el) => {
        el.classList.add('is-in');
      });
    } else {
      // Set up the observer as normal
      const observer = new IntersectionObserver(/* ... */);
      document.querySelectorAll('[data-animate]').forEach((el) => observer.observe(el));
    }
  6. 6

    Add a Graceful Fallback for Old Browsers

    IntersectionObserver is supported in all modern browsers. For the rare older environment where it is unavailable, immediately show all elements rather than leaving the page permanently blank.

    javascript
    if (!('IntersectionObserver' in window)) {
      // No IntersectionObserver — show everything immediately
      document.querySelectorAll('[data-animate]').forEach((el) => {
        el.classList.add('is-in');
      });
    } else {
      // Modern path: set up observer
    }
  7. 7

    Use Scroll-Driven Animations as the Modern Alternative

    The CSS animation-timeline: view() API links an animation directly to how much of the element is in the viewport — no JavaScript at all. Use @supports to layer it on top of your IntersectionObserver fallback. Supported in Chromium 115+ and Firefox 110+.

    css
    @supports (animation-timeline: view()) {
      [data-animate] {
        /* override the JS-dependent approach */
        opacity: 0;
        transform: translateY(20px);
        animation: fade-up linear forwards;
        animation-timeline: view();
        animation-range: entry 0% entry 30%;
      }
    
      @keyframes fade-up {
        to {
          opacity: 1;
          transform: translateY(0);
        }
      }
    }
  8. 8

    Stagger Multiple Elements for Polish

    When animating several sibling elements (a grid of cards, a list of features), a small stagger delay between each item looks far more intentional than all of them firing simultaneously. Use a CSS custom property set inline from JavaScript.

    javascript
    // Set a stagger delay on each observed element
    document.querySelectorAll('[data-animate]').forEach((el, index) => {
      el.style.setProperty('--stagger', `${index * 80}ms`);
      observer.observe(el);
    });

Tips & gotchas

  • Set <code>transition-delay: var(--stagger, 0ms)</code> on <code>[data-animate]</code> in your CSS so the stagger variable is consumed automatically without any extra JS.
  • Keep animations short — 400–600ms is the sweet spot. Anything above 700ms feels sluggish and makes users wait for content.
  • Animate <code>opacity</code> and <code>transform</code> only. Never animate <code>height</code>, <code>top</code>, <code>left</code>, or <code>margin</code> — these trigger layout and cause jank.
  • Elements near the top of the page may already be in the viewport on load. Run the observer callback once immediately after page load or add those elements to <code>.is-in</code> by default.

Wrapping up

The IntersectionObserver pattern — data-animate + CSS transition + observer + unobserve — is the production-ready approach today. It requires fewer than 20 lines of JavaScript, performs without scroll listeners, and works in every modern browser. Layer the CSS scroll-driven animation API on top for supporting browsers, and you have a future-proof solution that gets progressively faster as browser support matures.

#CSS #Animation #IntersectionObserver
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.