Use CSS scroll-driven animations (animation-timeline: view() or scroll()) for effects whose progress should track the scroll position continuously, and IntersectionObserver for discrete, one-time state changes — and when you want a scroll-linked effect everywhere, ship the CSS version with an observer-based fallback.
Problem / Scenario Context
A team is rebuilding a long-form article page. It has three kinds of scroll effects: a reading-progress bar at the top, images that scale up slightly as they cross the middle of the screen, and pull quotes that fade in once. The existing implementation drives all three from one IntersectionObserver with a 101-value threshold array, updating inline styles on every callback. It is janky on phones and burns battery.
Scroll-driven animations landed in Chromium-based browsers and have been arriving elsewhere since. The question is no longer whether they work but which effect belongs in which mechanism. The Scroll-Triggered Reveal Animations topic covers the observer side; this page draws the line.
Mechanics Explanation
Scroll-driven animations replace an animation's time-based timeline with a scroll-based one. scroll() maps a scroll container's scroll offset to animation progress; view() maps an element's progress through its scrollport — from entering to leaving — to animation progress. When the animated properties are compositor-friendly (transform, opacity, some filters), the browser can update them on the compositor thread in the same frame as the scroll, without running any JavaScript and without waiting for the main thread.
IntersectionObserver reports threshold crossings to the main thread, after paint, in a task. It is ideal for triggering something — a class, a fetch, a video play — and a poor fit for continuously setting a value, because each update costs a task and lands a frame late.
Emulating a continuous effect with a dense threshold array multiplies tasks while scrolling and still produces stepped, delayed updates. That is the page's jank.
Comparison Table: Which Effect Goes Where
| Effect | Continuous or discrete? | Best mechanism | Fallback |
|---|---|---|---|
| Reading-progress bar | continuous | animation-timeline: scroll(root) |
observer on section sentinels, coarse steps |
| Image scale while crossing | continuous | animation-timeline: view() |
no effect (static image) |
| Fade in once | discrete | IntersectionObserver + class | visible by default |
| Pause video off-screen | discrete, side effect | IntersectionObserver | none needed |
| Parallax background | continuous | view() on transform |
static background |
| Load next page | discrete, side effect | IntersectionObserver | "load more" button |
Minimal Reproducible Example
// The janky emulation: 101 thresholds, an inline style per callback.
const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);
new IntersectionObserver((entries) => {
for (const e of entries) {
(e.target as HTMLElement).style.transform = `scale(${0.9 + e.intersectionRatio * 0.1})`;
}
}, { threshold: thresholds }).observe(document.querySelector('figure')!);
Record a trace while scrolling: the main thread shows a task per crossing, and each one invalidates style for the figure. On a throttled CPU, the scale visibly lags the scroll and moves in steps.
Production-Safe Solution
Move continuous effects to CSS, gated by @supports, and keep the observer only where a discrete trigger or a side effect is needed.
/* 1. Reading progress: tied to the root scroller. */
.progress {
position: fixed; inset: 0 0 auto 0; height: 3px;
transform-origin: 0 50%;
transform: scaleX(0);
}
@supports (animation-timeline: scroll()) {
.progress {
animation: progress-grow linear both;
animation-timeline: scroll(root block);
}
}
@keyframes progress-grow { to { transform: scaleX(1); } }
/* 2. Image scale while crossing the scrollport. */
@supports (animation-timeline: view()) {
.article figure img {
animation: settle linear both;
animation-timeline: view();
animation-range: entry 0% cover 50%;
}
}
@keyframes settle { from { transform: scale(0.92); opacity: 0.6; } to { transform: none; opacity: 1; } }
/* 3. Motion safety for all of the above. */
@media (prefers-reduced-motion: reduce) {
.article figure img { animation: none; }
}
// 4. Discrete fade-ins stay on the observer (supported everywhere).
// 5. Fallback progress bar only where scroll timelines are missing.
export function progressFallback(sections: HTMLElement[], bar: HTMLElement): () => void {
if (CSS.supports('animation-timeline: scroll()')) return () => {};
const seen = new Set<Element>();
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) seen.add(e.target);
bar.style.transform = `scaleX(${seen.size / sections.length})`; // coarse, per section
}, { rootMargin: '0px 0px -60% 0px' });
sections.forEach((s) => io.observe(s));
return () => io.disconnect();
}
The fallback deliberately does less: it advances in steps per section rather than emulating a smooth bar with hundreds of thresholds. Users on engines without scroll timelines get a functional indicator without the main-thread cost.
Accessibility and Robustness Differences
The two mechanisms fail differently, and that shapes the defaults.
A scroll-driven animation never hides content permanently: if the timeline is inactive (unsupported, or the element cannot scroll), animation-fill-mode: both holds the first or last keyframe, so design the from keyframe to be readable (for example opacity: 0.6, not 0). Reduced-motion users should get animation: none, which leaves the element in its un-animated, fully visible state.
An observer-driven reveal hides content until a script runs, so it needs the opt-in class pattern described in fade in on scroll. Its advantage is that the final state is permanent: once revealed, content never fades back out if the user scrolls backwards — which scroll-linked animations do by definition, because they are tied to position, not history.
That backwards behaviour is the deciding factor for text. A pull quote that fades out as the user scrolls back up to re-read the paragraph above it is disorienting; a one-shot reveal is not. Reserve scroll-linked animation for decoration.
Migrating an Observer-Driven Effect to a Timeline
When an existing effect is clearly continuous, the migration is mostly deletion. A practical sequence:
- Write the keyframes that describe the effect from start to end, using the values your callback computed at ratio 0 and ratio 1.
- Pick the timeline. Effects tied to the whole page use
scroll(root); effects tied to one element crossing the screen useview(). Nested scroll containers usescroll(nearest)or a namedscroll-timeline. - Pick the range.
animation-range: entryruns while the element enters;coverspans entering to leaving;containruns only while it is fully inside. Map your old threshold window onto one of these. - Gate with
@supportsand keep the observer path only for engines without support — or drop it, if a static fallback is acceptable. - Delete the threshold array and the inline style writes. Confirm in a trace that the main-thread tasks during scroll have gone.
The old callback's intersectionRatio maps loosely onto view() progress, but not exactly: the ratio measures how much of the element is visible, while view() progress measures how far the element has travelled through the scrollport. For tall elements they diverge completely — a tall element can be at ratio 0.3 for most of its journey. Re-tune the keyframes by eye after migrating rather than assuming the numbers carry over.
Verification Steps
- Check
CSS.supports('animation-timeline: view()')in each target browser and confirm the correct path is active. - Record a trace while scrolling in a supporting browser; scroll-driven effects should add no main-thread tasks.
- Scroll up past animated text and confirm nothing important fades out.
- Emulate reduced motion and confirm continuous effects are disabled and content is fully visible.
- Test the fallback in a non-supporting browser and confirm the progress bar advances per section.
Common Mistakes to Avoid
- Dense threshold arrays for continuous effects. They multiply main-thread tasks and still look stepped.
- A fully transparent
fromkeyframe on scroll-driven text. If the timeline is inactive, the text can stay invisible. - Scroll-linking body text. It reverses on scroll-up and makes re-reading harder.
- Forgetting
animation-range. The default range forview()spans entry to exit, so an effect meant to finish mid-screen keeps animating until the element leaves.
FAQ
Do scroll-driven animations replace IntersectionObserver?
No. They replace the continuous, position-linked uses that observers were never good at. Anything that needs a side effect — loading data, playing media, analytics — or a one-way state change still belongs to an observer.
Can JavaScript read the progress of a scroll-driven animation?
Yes, through the Web Animations API: element.getAnimations() returns animations whose timeline is a ScrollTimeline or ViewTimeline, and their currentTime reflects progress. Reading it every frame reintroduces main-thread work, so do so sparingly.
Are scroll-driven animations always on the compositor?
Only when the animated properties can be composited, mainly transform and opacity. Animating width, top or colour falls back to the main thread, losing the main benefit.
What does view() measure against?
The nearest ancestor scroll container, by default on the block axis. It measures the element's progress from the moment its leading edge enters the scrollport to the moment its trailing edge leaves, which animation-range subdivides into named phases like entry, cover and exit.
Related
- Fade In on Scroll with IntersectionObserver and CSS — the discrete side
- Reading Progress Bar with IntersectionObserver — the fallback in depth
- Why Observer Callbacks Lag One Frame Behind — why continuous effects suffer on the main thread
↑ Back to Scroll-Triggered Reveal Animations