CSS scroll-driven animations vs IntersectionObserver
Choosing between animation-timeline: view() and IntersectionObserver comes down to one architectural question: does your effect need a continuously interpolated progress value, or a binary crossed/not-crossed state? Understanding the CSS Scroll-Timeline API explains how view() maps element visibility to an animation progress fraction — that fraction is computed on the compositor thread, which runs independently of JavaScript and can sustain 60 fps even when the main thread is busy.
When to use each approach
Use animation-timeline: view() when:
- The visual change is continuously proportional to scroll progress — fades, reveals, parallax offsets, or scale transforms that should track the scroll position frame-by-frame.
- Compositor safety is the priority — the effect only touches
transform,opacity, orfilter, which the rendering pipeline can handle without touching style or layout. - The project targets Chrome 115+ and Safari 17.4+ (or will use a scroll-timeline polyfill for older Safari).
- The animation range is expressible with
entry,exit,cover, orcontainkeywords — no custom JavaScript math required.
Keep IntersectionObserver when:
- You need to fire a side effect — load an image, increment a counter, log an analytics event — on a threshold crossing. CSS animations cannot trigger imperative code.
- The only output is a binary class toggle (
.in-view/.out-of-view). IntersectionObserver’s threshold API does this with minimal overhead and no continuous rAF loop. - You need Firefox support today (scroll-driven animations are behind a flag in Firefox as of 2026). IntersectionObserver is baseline across all engines.
- The element lives in a cross-origin iframe where compositor scroll offset is not exposed to the parent document.
Neither approach is universally better. For discrete state changes with broad browser reach, IntersectionObserver is the pragmatic choice. For smooth, scrub-synced motion on supported browsers, animation-timeline: view() cannot be matched from the main thread.
Implementation
Step 1 — Audit your existing IntersectionObserver callbacks
Identify whether each callback is triggering a continuous animation or a discrete state change. Callbacks that only add/remove a class and then hand off to CSS transition are the strongest migration candidates:
// Before: IntersectionObserver triggering a class-toggled CSS transition
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('visible', entry.isIntersecting);
});
}, { threshold: 0.2 });
document.querySelectorAll('.card').forEach(el => observer.observe(el));
/* The transition fires only once, at the threshold crossing */
.card {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.4s ease, transform 0.4s ease;
}
.card.visible {
opacity: 1;
transform: translateY(0);
}
This is the exact pattern animation-timeline: view() replaces: the JavaScript ceremony exists only because there was no native way to express “animate as the element scrolls into view”.
Step 2 — Replace with animation-timeline: view()
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: card-enter linear;
animation-timeline: view();
/* Start when the card's leading edge crosses the viewport bottom;
finish when its trailing edge clears the viewport top at 50% cover */
animation-range: entry 0% cover 50%;
will-change: opacity, transform;
}
The animation-range: entry 0% cover 50% window maps to what IntersectionObserver expressed with multiple thresholds. entry 0% is the moment the element begins entering the viewport; cover 50% is when the element is half covered by the scroller. Remove the JavaScript observer entirely — the CSS handles everything.
Step 3 — Add a progressive enhancement guard
Wrap the scroll-driven declarations so browsers without support receive the static end state instead of staying invisible:
/* Static fallback: all browsers see the element */
.card {
opacity: 1;
transform: translateY(0);
}
/* Scroll-driven animation for supporting browsers only */
@supports (animation-timeline: scroll()) {
.card {
opacity: 0;
transform: translateY(24px);
animation: card-enter linear;
animation-timeline: view();
animation-range: entry 0% cover 50%;
will-change: opacity, transform;
}
}
Pair this with the JavaScript guard for any remaining IntersectionObserver-based fallback logic:
if (!CSS.supports('animation-timeline', 'scroll()')) {
// Fallback: threshold-based class toggle for browsers without native support
const observer = new IntersectionObserver(
(entries) => entries.forEach(e =>
e.target.classList.toggle('visible', e.isIntersecting)
),
{ threshold: 0.2 }
);
document.querySelectorAll('.card').forEach(el => observer.observe(el));
}
Step 4 — Handle side effects that must survive the migration
If the original observer also triggered a side effect (lazy-loading an image, logging analytics), retain a separate IntersectionObserver for just the side effect and let CSS handle the visual animation:
// Separate observer for the side effect only — not the animation
const analyticsObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
trackImpression(entry.target.dataset.id);
analyticsObserver.unobserve(entry.target); // one-shot
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.card[data-id]').forEach(el =>
analyticsObserver.observe(el)
);
The CSS scroll-driven animation and the analytics observer now operate independently with no coordination overhead.
Verification
DevTools check: confirm compositor isolation
After migrating to animation-timeline: view():
- Open DevTools → Performance panel. Start a recording, scroll slowly through the animated cards, stop the recording.
- Filter the flame chart for Layout and Recalculate Style events.
- With a correctly implemented CSS scroll-driven animation, these events are absent during the scroll phase. Any
Layoutevent originating from a scroll handler is a sign that an old observer callback is still running. - In the Animations panel, click the animation target in the elements tree. A
ViewTimelineentry should appear, with a scrubber you can drag to manually advance the scroll position. - In Elements → Computed, confirm
animation-timelineresolves to aViewTimelineobject. If it showsnone, the@supportsguard may have excluded the declaration on this browser version.
Visual regression test
// Playwright: scroll the page and assert the element is visible mid-scroll
import { test, expect } from '@playwright/test';
test('card is visible when scrolled into view', async ({ page }) => {
await page.goto('/');
const card = page.locator('.card').first();
// Scroll until the card is in the viewport
await card.scrollIntoViewIfNeeded();
await expect(card).toBeVisible();
// Verify opacity has progressed above zero (CSS animation is running)
const opacity = await card.evaluate(el =>
parseFloat(getComputedStyle(el).opacity)
);
expect(opacity).toBeGreaterThan(0);
});
Edge cases and gotchas
Layout thrashing in mixed observer code. If any IntersectionObserver callback remaining in the codebase calls getBoundingClientRect() inside the callback, it forces a synchronous layout during the scroll phase and can jank CSS scroll-driven animations on the same page. Audit all remaining observers and move layout reads outside callbacks using a requestAnimationFrame dequeue pattern.
Framework re-renders resetting animation-play-state. React’s reconciler and Vue’s virtual DOM can overwrite computed styles when a component re-renders. If a parent re-renders while a scroll-driven animation is mid-progress, the element may flicker back to its from state. Use will-change on the element to promote it to a separate compositor layer — this prevents a full repaint from resetting the animation. For React specifically, wrapping the animated element with React.memo prevents unnecessary reconciliation from upstream state changes.
// React: check animation attachment after mount; re-connect if the framework reset it
import { useEffect, useRef } from 'react';
export function ScrollCard({ children }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// If reconciliation cleared the animation, restart it
if (el.getAnimations().length === 0) {
el.style.animationPlayState = 'running';
}
}, []);
return <div ref={ref} className="card">{children}</div>;
}
contain: strict blocks snapshot capture. Applying contain: strict to a scroll container creates a new stacking and paint context. If you are also using @view-transition on the same element, the browser cannot capture the pre-transition snapshot of contained elements. Use contain: layout style (omitting paint) to preserve view-transition snapshot access while still limiting invalidation propagation.
Threshold semantics differ between the two approaches. IntersectionObserver’s threshold: 0.5 means “fire when 50% of the element is visible”. animation-range: entry 50% means “start the animation when the element’s leading edge is 50% past the viewport edge” — not the same geometry. Map thresholds carefully when porting. The cover and contain keywords in animation-range provide the closest semantic equivalents to IntersectionObserver’s intersection ratio.
prefers-reduced-motion must disable the animation, not just slow it. Setting animation-duration: 0.001s under prefers-reduced-motion: reduce still runs the animation — it just snaps instantly. The correct pattern is to remove the animation-timeline declaration entirely so the element displays in its end state with no motion:
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
opacity: 1;
transform: none;
}
}
Browser-specific notes
Chrome 115–128: animation-range shorthand parsing had a minor edge case where a single keyword like animation-range: entry was silently ignored. Use the explicit two-value form animation-range: entry 0% entry 100% or the longhand animation-range-start / animation-range-end properties. Chrome 129+ fixed the shorthand parser.
Safari 17.4–18: view() does not accept the inline axis argument. Horizontal scroll-reveal effects using animation-timeline: view(inline) fall back silently to no animation. Test horizontal timelines explicitly. For Safari 17.4 the workaround is a named view-timeline with view-timeline-axis: inline on the scroll container.
Firefox: CSS scroll-driven animations are available behind the layout.css.scroll-driven-animations.enabled flag. Without the flag, Firefox users see whatever is in the non-@supports block. IntersectionObserver is fully supported across all Firefox versions since Firefox 55. If Firefox share is significant in your analytics, keep the IntersectionObserver fallback and confirm it does not conflict with the CSS path on supported browsers.
Safari < 17.4: Neither animation-timeline: view() nor scroll() is supported. The @supports (animation-timeline: scroll()) guard correctly excludes the scroll-driven declarations. Users receive the static fallback defined outside the @supports block. See the scroll-timeline polyfill guide for Safari for a rAF-based approach that bridges the gap.
Related
- Understanding the CSS Scroll-Timeline API — parent cluster covering
scroll()andview()syntax, named timelines, and the full API surface - The Rendering Pipeline for Scroll Animations — compositor thread mechanics and 16ms frame budget analysis
- Browser Support & Progressive Enhancement —
@supportsguard recipes and cross-browser shipping matrix - How to Polyfill scroll-timeline for Safari — rAF-based fallback for Safari < 17.4 with SPA cleanup patterns
- Core Animation Fundamentals & Browser Mechanics — pillar overview: compositor model, spec versions, and rendering pipeline