Step-by-step
-
1
Mark Elements with data-animate
Add a
data-animateattribute 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
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
Define the Visible State with .is-in
When an element has the
.is-inclass, 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
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.
javascriptconst 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
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
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.
javascriptif (!('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
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@supportsto 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
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.