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.

Where Each Mechanism Runs and What It Is Good AtA grid comparing CSS scroll-driven animations and IntersectionObserver on four properties. Scroll-driven animations run on the compositor, update every frame in sync with scroll, cannot trigger side effects like fetches, and are not yet supported in every browser. IntersectionObserver runs on the main thread, updates one frame late at crossings, can trigger any side effect, and is supported everywhere.Runs onUpdate timingSide effectsSupportScroll-driven CSScompositorsame frame, continuousnonemodern enginesIntersectionObservermain threadnext frame, at crossingsanythingeverywhere

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

TypeScript
// 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.

CSS
/* 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; }
}
TypeScript
// 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.

Main-Thread Tasks per Second of ScrollingA bar chart. Emulating a continuous scale with a 101-value threshold array produces around sixty main-thread tasks per second while an image crosses the viewport. A scroll-driven view timeline produces none. A discrete fade-in observer produces one task per element in total, and the coarse fallback progress bar produces one task per section boundary.While one figure crosses the viewport, per second101-threshold observer~60 tasksview() timeline0 tasksdiscrete fade observer1 task, oncefallback progress1 per section

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.

Failure Modes of Each MechanismTwo columns. Scroll-driven animations degrade to a held keyframe when unsupported, animate backwards when the user scrolls up, and are disabled cleanly with animation none. Observer reveals depend on a script to unhide content unless gated, stay revealed permanently once triggered, and need an explicit reduced-motion path in CSS.Scroll-driven CSSUnsupported: holds a keyframe, so make it readableReverses when the user scrolls back upReduced motion: animation: none, content staysvisibleObserver revealNeeds the opt-in class, or content can stay hiddenPermanent once revealed; never reversesNeeds its own reduced-motion rule in CSS

Migrating an Observer-Driven Effect to a Timeline

When an existing effect is clearly continuous, the migration is mostly deletion. A practical sequence:

  1. Write the keyframes that describe the effect from start to end, using the values your callback computed at ratio 0 and ratio 1.
  2. Pick the timeline. Effects tied to the whole page use scroll(root); effects tied to one element crossing the screen use view(). Nested scroll containers use scroll(nearest) or a named scroll-timeline.
  3. Pick the range. animation-range: entry runs while the element enters; cover spans entering to leaving; contain runs only while it is fully inside. Map your old threshold window onto one of these.
  4. Gate with @supports and keep the observer path only for engines without support — or drop it, if a static fallback is acceptable.
  5. 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 from keyframe 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 for view() 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.


↑ Back to Scroll-Triggered Reveal Animations