CSS Scroll-Driven Animations Explained
CSS scroll-driven animations tie keyframes to scroll position instead of a clock, running smoothly off the main thread. Here's how the timeline model works.
Scroll-driven animations are CSS animations whose progress is tied to scroll position instead of the clock. Rather than an animation running for a fixed duration, it plays forward and backward as the user scrolls — a progress bar that fills as you scroll down a page, an image that fades in as it enters the viewport, a parallax header that shrinks on scroll. The mechanism is the animation-timeline property, and it lets the browser drive these effects without a single line of JavaScript.
The old way: JS scroll listeners
Before this landed in CSS, scroll-linked effects meant listening for scroll events, reading getBoundingClientRect() or using an IntersectionObserver, and updating styles in a requestAnimationFrame callback. It works, but every frame runs JavaScript on the main thread — the same thread handling input, layout, and paint. On a busy page, or on a slower device, that’s exactly where jank creeps in, and it’s the kind of cost that shows up in Core Web Vitals as poor responsiveness.
Two kinds of timelines
CSS scroll-driven animations give you two timeline sources:
animation-timeline: scroll()— the timeline is the scroll position of a scrollable container (or the document itself). Progress runs from 0% at the top of the scroll range to 100% at the bottom.animation-timeline: view()— the timeline is tied to an element’s visibility inside a scroller. Progress runs from the moment the element enters the viewport to the moment it exits, which is what most “reveal on scroll” effects actually want.
A basic reveal-on-scroll effect looks like this:
.card {
animation: fade-in linear;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
animation-range narrows down which portion of the timeline maps to the animation — here, from the moment the card starts entering the viewport to 40% of the way through being fully covered. Named ranges like entry, contain, cover, and exit describe different phases of an element’s journey through the scrollport, so you can trigger effects precisely without measuring pixels by hand.
Why this matters for performance
Because the browser owns the timeline, it can run these animations on the compositor thread — the same thread that already handles smooth scrolling — instead of the main thread. That means no JavaScript execution per frame, no forced synchronous layout reads, and no risk of a slow scroll handler dropping frames. It’s a similar performance philosophy to the View Transitions API: move animation work out of application JavaScript and into a layer the browser can optimize natively. Understanding why this matters is easier if you’re familiar with the critical rendering path — anything that avoids extra layout and paint work on the main thread is a direct win for perceived smoothness.
Scroll-driven vs JavaScript-based scroll animation
| JS scroll/IntersectionObserver | CSS animation-timeline | |
|---|---|---|
| Runs on | Main thread | Compositor thread |
| Jank risk | Higher under load | Lower |
| Setup | Manual math, listeners, rAF loop | Declarative CSS |
| Dynamic logic | Full JS control | Limited to CSS animation model |
| Fallback needed | No | Yes, for unsupported browsers |
A scroll-linked progress indicator
A common use case is a reading-progress bar tied to the whole document’s scroll position rather than a single element’s visibility, which is where scroll() rather than view() is the right timeline source:
#progress-bar {
animation: grow linear;
animation-timeline: scroll(root);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
scroll(root) ties the animation to the document’s own scroll container rather than a nested scrollable element, so the bar fills in lockstep with how far down the page the user has scrolled — no scroll listener, no manual percentage math, and no risk of the calculation drifting out of sync with the actual scroll position on a fast flick.
Practical considerations
Scroll-driven animations are declarative, which is their strength and their limit — you can’t easily branch logic mid-animation the way you can in a scroll handler. For anything beyond a fade, scale, or translate keyed to scroll progress, you may still reach for JavaScript.
Browser support for animation-timeline varies, so treat it as a progressive enhancement: define a static, sensible default state and layer the scroll-driven behavior with @supports (animation-timeline: view()), rather than making the effect load-bearing for content visibility. This mirrors how you’d handle any newer CSS feature — container queries and cascade layers both benefit from the same “enhance, don’t require” mindset. Also be mindful of prefers-reduced-motion: scroll-triggered movement is exactly the kind of effect that should be dampened or disabled for users who’ve opted out of animation.
The takeaway
CSS scroll-driven animations move scroll-linked effects out of JavaScript and onto the compositor, using scroll() or view() timelines and animation-range to control when an animation plays relative to scroll position. They’re a natural fit for reveal effects, progress indicators, and parallax — anything that used to need a scroll listener and a rAF loop. Ship them as an enhancement behind a static fallback, respect reduced-motion preferences, and let the browser do the frame-by-frame work it’s already good at.
Keep reading
Takina · · 5 min read CSS will-change Explained: Compositing and Performance
The CSS will-change property hints the browser to prepare an element for an upcoming change, moving it to its own compositor layer. When to use it and when not to.
Takina · · 4 min read CSS Container Queries: Components That Adapt Anywhere
Container queries let components respond to the space they're given, not the viewport. Here's how they work and when to reach for them.
Takina · · 4 min read What Is the Beacon API? navigator.sendBeacon()
The Beacon API lets a page send one last async request as it unloads, without blocking navigation or racing the browser's page teardown.