WSS
Web Specification Studio Home
On this page
PerformanceOptionalUpdated

Scroll-driven animations

Drive CSS animations from scroll position or element visibility with `scroll-timeline` and `view-timeline`, replacing JS scroll-listener libraries with compositor-thread animation.

What it is

Scroll-driven animations tie a CSS animation’s progress to scroll position instead of the wall clock. The browser provides two timelines.

scroll-timeline - progress maps to how far a scroll container has been scrolled. Useful for reading-progress bars and parallax tied to the document.

view-timeline - progress maps to a subject element’s intersection with its scrollport. Useful for “reveal as you scroll” effects, sticky-section pinning, and per-element entrance animations.

Anonymous shorthands cover the common cases without naming a timeline:

  • animation-timeline: scroll() - bind to the nearest scrollable ancestor.
  • animation-timeline: view() - bind to this element’s own viewport intersection.

The colour status is optional because nothing breaks if you ship none of this. Where motion is part of the design, however, it is the right answer: it replaces a category of janky JavaScript with a declarative primitive that the compositor can run.

Why it matters

  • Off the main thread. Compositor-only properties (transform, opacity, filter) animate without touching JavaScript or layout. No INP cost, no scroll-listener debouncing, no requestAnimationFrame loop.
  • No library needed. Replaces a stack of intersection-observer-plus-rAF code, often shipped as 20–60 kB of JavaScript, with a few lines of CSS.
  • Honours user preferences when you opt in. Because the motion is yours, you must gate it on prefers-reduced-motion: reduce - see reduced motion.
  • Composable with view transitions. Pair with view transitions for entrance + navigation animation that stays on the compositor.

How to implement

Fade an element in as it scrolls into view:

@keyframes fade-in {
  from { opacity: 0; transform: translateY(1rem); }
  to   { opacity: 1; transform: none; }
}

.reveal {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; }
}

animation-range tells the browser which slice of the view timeline drives the animation - here, from the element entering the scrollport until it has covered 30% of it.

For a top-of-page reading-progress bar, use scroll() instead:

.progress {
  transform-origin: 0 50%;
  animation: progress linear;
  animation-timeline: scroll(root block);
}
@keyframes progress { to { transform: scaleX(1); } }

Common mistakes

  • Animating layout properties (width, height, top, margin) instead of transform and opacity. The compositor win disappears the moment the browser has to relayout on every frame.
  • Forgetting prefers-reduced-motion. Scroll-driven motion is exactly the kind that triggers vestibular issues. Always provide an opt-out.
  • Replacing an Intersection Observer that runs logic. Scroll-driven animations animate; they do not fire callbacks. If you need to lazy-init a widget, hydrate an island, or fetch data on visibility, keep the observer - see visibility-aware rendering.
  • Reaching for scroll-timeline when view-timeline would do. Per-element reveal effects are simpler, more local, and easier to maintain with view(). Reserve scroll() for document-level effects like progress bars.
  • Long ranges with heavy keyframes. Keep ranges tight; an animation that runs over half the page tends to feel laggy even when it is technically free.

Compositor-Thread Execution & scroll-timeline / view-timeline Syntax

Offloading scroll animations from the main JavaScript thread to the browser compositor requires using native CSS scroll-driven animation primitives:

  • scroll-timeline vs view-timeline Functional Boundaries: Use animation-timeline: scroll(root block) to map animation progress to container scroll position (e.g. reading-progress header bars). Use animation-timeline: view() to map animation progress to an individual element’s intersection with the viewport (e.g. scroll-reveal cards).
  • Compositor Thread Optimization: Only animate compositor-friendly CSS properties (transform, opacity, filter). Animating layout-triggering properties (height, margin, top) forces main-thread layout recalculations on every scroll frame, destroying Interaction to Next Paint (INP) performance scores.
  • Configuring animation-range Constraints: Fine-tune scroll reveal progress using animation-range: entry 0% cover 30%;. This restricts animation timing strictly to the window phase where the element enters the scrollport.

Verification

  • Chrome DevTools → Animations panel records scroll-driven timelines and lets you scrub them.
  • Toggle prefers-reduced-motion: reduce in DevTools → Rendering. The motion must disappear, not merely shorten.
  • Run a Lighthouse or Web Vitals check. INP should be unaffected; if it regresses, you are animating the wrong property.
  • Confirm the page still works with JavaScript disabled - scroll-driven animations are pure CSS and should degrade gracefully where unsupported.

Related topics

Sources & further reading