On this page
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, norequestAnimationFrameloop. - 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 oftransformandopacity. 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-timelinewhenview-timelinewould do. Per-element reveal effects are simpler, more local, and easier to maintain withview(). Reservescroll()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-timelinevsview-timelineFunctional Boundaries: Useanimation-timeline: scroll(root block)to map animation progress to container scroll position (e.g. reading-progress header bars). Useanimation-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-rangeConstraints: Fine-tune scroll reveal progress usinganimation-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: reducein 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.