Scroll-Driven Animations: Binding Motion to Scroll Position in Pure CSS
Scroll-linked motion has been a JavaScript problem for two decades: attach a listener, read scrollTop, do arithmetic, write a style, and hope the main thread is free enough to do it sixty times a second. CSS scroll-driven animations delete that whole loop. A regular @keyframes rule is bound to a timeline that advances with scroll position rather than with the clock, and the browser evaluates it wherever it evaluates the scroll itself. This guide sits inside CSS-Only Micro-Interactions & Animations and covers the whole surface: animation-timeline, the anonymous scroll() and view() functions, named timelines, and the animation-range grammar that decides when along an element's travel the animation actually plays.
Prerequisites — this guide assumes you can already:
- Write a
@keyframesrule and attach it with theanimationshorthand (see the keyframe animation patterns guide). - Explain what
animation-fill-mode: bothdoes during a delay. - Recognise which CSS properties the compositor can animate without a layout pass.
- Use
@supportsfor feature detection. - Reason about a scroll container: an element with
overflowother thanvisiblethat has content larger than its box.
The Core Concept: An Animation Needs a Timeline, Not a Clock
Every CSS animation already has a timeline. Until scroll-driven animations existed you never had to name it, because there was only one: the document timeline, whose current time is milliseconds since the document loaded. animation-duration: 2s means "map the keyframes across two seconds of that timeline". The CSS Scroll-Driven Animations specification generalises this. It introduces two new kinds of progress-based timeline whose current time is not a duration at all but a percentage between 0% and 100%, derived from geometry.
A scroll progress timeline is anchored to a scroll container. Its 0% is that container's initial scroll offset and its 100% is its maximum scroll offset. Scroll halfway down and the timeline reads 50%. There is no time involved: fling the scroller and the timeline jumps; scroll backwards and it runs backwards; stop and it freezes.
A view progress timeline is anchored to a subject element and describes that element's own journey through the scrollport of its nearest scroll container. Its 0% is the moment the subject first begins to intersect the scrollport, and its 100% is the moment it has completely left. Every element on the page gets its own view timeline with its own independent progress. This is the primitive that replaces IntersectionObserver for visual effects.
Both are attached with the same property: animation-timeline. The <keyframes> rule, the animation-name, the keyframe stops — none of that changes. You are swapping out the thing that supplies progress, and nothing else.
Here is that swap at its smallest. Scroll the frame and watch the badge track the scrollbar exactly — there is no listener, no throttle, no requestAnimationFrame.
.badge {
/* No duration is honoured; progress comes from the scroller.
`linear` and `both` are the two defaults you almost always want. */
animation: badge-spin linear both;
animation-timeline: scroll(root);
}
@keyframes badge-spin {
from { transform: rotate(0deg) scale(0.7); }
to { transform: rotate(360deg) scale(1.15); }
}
Syntax and Parameters
| Token | Accepted values | Default |
|---|---|---|
animation-timeline | auto, none, <dashed-ident>, scroll(), view() | auto (the document timeline) |
scroll() scroller | nearest, root, self | nearest |
scroll() axis | block, inline, x, y | block |
view() axis | block, inline, x, y | block |
view() inset | auto, one or two <length-percentage> values | auto |
scroll-timeline-name | none, <dashed-ident> | none |
scroll-timeline-axis | block, inline, x, y | block |
view-timeline-name | none, <dashed-ident> | none |
view-timeline-inset | auto, one or two <length-percentage> values | auto |
timeline-scope | none, one or more <dashed-ident> | none |
animation-range-start | normal, <length-percentage>, <timeline-range-name> <length-percentage>? | normal |
animation-range-end | same grammar as animation-range-start | normal |
<timeline-range-name> | cover, contain, entry, exit, entry-crossing, exit-crossing | — |
Three of these deserve prose before you use them.
scroll(root) versus scroll(nearest) is the distinction that causes the most confusion. nearest walks up the ancestor chain from the animated element and picks the first scroll container it finds — which, on a page with no nested scrollers, is the document, making the two look identical until the day someone wraps your component in an overflow: auto panel. Say root when you mean the document and nearest when you mean "whatever panel I happen to be inside".
The view() inset shrinks the notional scrollport used to compute the timeline. view(block 25% 0%) says: pretend the top quarter of the scrollport is not there. Since the timeline's 0% is defined by first intersection with the scrollport, insetting the top delays the whole timeline until the element is a quarter of the way up the screen. This is how you stop reveals from firing on content that is technically visible but sitting under a sticky header.
animation-range is a shorthand for the start and end pair, and its grammar is two range names, not one: animation-range: entry 0% cover 40% means "start when the entry phase is 0% complete and end when the cover phase is 40% complete". Both halves are independent; mixing phase names across the two halves is normal and intended, not a mistake.
Step-by-Step Implementation
Step 1 — Bind a keyframe rule to the document scroller
Start with the simplest possible case: something that reflects overall document progress. The only new line is animation-timeline.
.doc-indicator {
transform-origin: 0 50%;
animation: fill-bar linear;
animation-timeline: scroll(root block);
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Note that animation-duration is absent. It would be ignored anyway; progress is geometric. What you must not omit is linear. The default timing function is ease, and an ease curve applied on top of a scroll mapping means the element lurches ahead of your finger at the start and lags behind it at the end. Shape the motion with keyframe percentages instead, and leave the timing function alone.
Step 2 — Switch to a per-element timeline with view()
scroll() gives every element on the page the same progress value. That is wrong for reveals, where each card should animate according to its own position. view() fixes this without any extra plumbing: written on the element itself, it creates a timeline whose subject is that element.
.reveal {
animation: rise linear both;
animation-timeline: view();
}
@keyframes rise {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
At this point the animation runs across the full cover range, meaning it plays forward as the element enters and backward as it leaves — the element fades out again at the top of the screen. That is often not what you want, which is what step three is for.
Step 3 — Constrain the play window with animation-range
Restrict the range so the animation completes early and stays completed.
.reveal {
animation: rise linear both;
animation-timeline: view();
/* Begin the instant the element's leading edge crosses in;
be finished by the time it is 40% of the way across the scrollport. */
animation-range: entry 0% cover 40%;
}
Because the range ends at cover 40%, everything past that point is "after the animation" — and animation-fill-mode: both holds the final keyframe there. The card rises once and stays put. Here is the difference between an early-finishing range and one that spans the whole pass:
.box {
animation: box-in linear both;
animation-timeline: view();
}
/* Done by the time the box is 40% up the scrollport — a one-way reveal. */
.early { animation-range: entry 0% cover 40%; }
/* Runs across the element's entire pass through the scrollport, so it plays
forwards on the way in and backwards on the way out. */
.full { animation-range: cover 0% cover 100%; }
@keyframes box-in {
from { opacity: 0; transform: translateY(24px) scale(0.92); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
Step 4 — Name a timeline when the driver is not the animated element
scroll() and view() are anonymous timelines: they can only describe the element they are written on, or its ancestor scroller. When element A's animation must be driven by element B's geometry, you need a named timeline. Element B declares view-timeline-name (or scroll-timeline-name), and element A references that name.
/* The driver: a long section whose passage supplies the progress. */
.chapter {
view-timeline-name: --chapter-pass;
view-timeline-axis: block;
}
/* The driven element: a sibling caption, animated by the chapter's travel. */
.chapter-caption {
animation: slide-label linear both;
animation-timeline: --chapter-pass;
animation-range: contain 0% contain 100%;
}
@keyframes slide-label {
from { transform: translateX(-1rem); opacity: 0.4; }
to { transform: translateX(0); opacity: 1; }
}
Timeline names resolve down the tree, not sideways: an element can reference a timeline declared on itself, on an ancestor, or on a preceding sibling of an ancestor. If the driver is somewhere else entirely, hoist the name into a common ancestor's scope with timeline-scope: --chapter-pass, which makes the name visible to that ancestor's whole subtree.
Step 5 — Gate the whole thing behind @supports
A browser that does not understand animation-timeline will still parse animation: rise linear both and run it on the document timeline — with no duration, which resolves to 0s, which means the element snaps to its end state instantly. That is a benign failure, but only by accident. Make it deliberate:
.reveal { opacity: 1; }
@supports (animation-timeline: view()) {
.reveal {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
The scroll-driven animation fallbacks page works through the full decision tree here, including what to do when the enhanced state and the base state disagree about layout.
Annotated Production Example: A Chapter Header That Compresses
A realistic component: a sticky section header that shrinks, tightens its letter spacing, and gains a shadow as its section scrolls past. Done with a scroll listener this is a notorious jank source, because every frame reads layout and writes style. Done with a named view timeline it is four declarations.
<section class="chapter">
<header class="chapter__bar">
<h2 class="chapter__title">Deployment</h2>
<span class="chapter__meta">6 min read</span>
</header>
<div class="chapter__body">
<p>Section content goes here, long enough to scroll.</p>
<p>More content.</p>
<p>Still more content.</p>
</div>
</section>
.chapter {
/* The section itself is the driver. Its pass through the scrollport
supplies progress to anything that names this timeline. */
view-timeline-name: --chapter;
view-timeline-axis: block;
position: relative;
}
.chapter__bar {
position: sticky;
top: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: Canvas;
/* Animating a custom property keeps the compressed state in one place. */
--compress: 0;
}
.chapter__title {
margin: 0;
font-size: 1.6rem;
/* transform, not font-size: font-size would relayout every frame. */
transform: scale(calc(1 - 0.25 * var(--compress)));
transform-origin: left center;
}
.chapter__meta {
opacity: calc(1 - var(--compress));
}
@supports (animation-timeline: view()) {
.chapter__bar {
animation: compress linear both;
animation-timeline: --chapter;
/* Only compress while the section is actually being read: from the
moment it fully covers the scrollport to the moment it starts leaving. */
animation-range: contain 0% exit 30%;
}
@keyframes compress {
from { --compress: 0; box-shadow: 0 0 0 rgba(0, 0, 0, 0); }
to { --compress: 1; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); }
}
}
Two details are doing real work. First, the animation targets --compress rather than font-size, so a single interpolated scalar feeds several visual properties; interpolating a custom property at all requires it to be registered, which is covered in animating custom properties with @property. Second, the range is contain 0% exit 30% — the header compresses only while its section genuinely occupies the screen, so a short section never triggers a compression the reader has no time to perceive.
Performance and Accessibility Notes
The performance claim for scroll-driven animations is specific, and it is worth being precise about it because the marketing version overstates it.
What is genuinely off the main thread: the timeline evaluation. The compositor already knows the scroll offset — it is the thing producing the scroll. Sampling a scroll progress timeline is arithmetic the compositor can do on its own thread, in the same frame as the scroll itself. A JavaScript scroll listener, by contrast, is dispatched to the main thread, which means the effect is always at least one frame behind the scroll and stops entirely whenever a long task blocks that thread. This is the difference readers feel: a JS progress bar visibly lags a fast flick; a CSS one does not.
What is not automatically off the main thread: the animated properties themselves. If your keyframes animate width, height, top, or font-size, the compositor must hand the frame back to the main thread for layout, and you have reintroduced the jank you were trying to avoid — with a mechanism that now runs on every scroll frame rather than a throttled subset. Restrict scroll-driven keyframes to transform, opacity, and filter, exactly as you would for time-based animation. The reasoning behind that shortlist is laid out in will-change and the compositor thread.
Animating a registered custom property, as in the example above, is a partial exception: the interpolation is cheap but the properties it feeds must still be compositor-friendly for the whole chain to stay off the main thread.
The accessibility obligation is heavier here than for ordinary animation, and it is not optional. Motion that is coupled to the scroll gesture is one of the strongest vestibular triggers there is, because the visual field moves in a way that contradicts what the user's proprioception predicts from their scroll input. Parallax — where different layers move at different rates — is the worst offender, and WCAG treats it as a genuine accessibility barrier rather than a taste question. Every scroll-driven effect that changes an element's position or scale needs a prefers-reduced-motion: reduce branch that removes the positional component:
@media (prefers-reduced-motion: reduce) {
.reveal {
/* Kill the timeline binding outright; the element is simply present. */
animation-timeline: none;
animation: none;
opacity: 1;
transform: none;
}
}
Note that opacity-only scroll effects are usually acceptable under reduced motion, since nothing traverses the visual field — but a progress indicator that shrinks to nothing is not, because you have removed information. Prefer keeping the indicator and dropping the animation. The vestibular-safe animation patterns page catalogues which transformations are safe to keep.
One more accessibility trap: never put content behind a scroll-driven reveal that starts at opacity: 0 without a fallback. If the timeline never advances — because the container turned out not to be scrollable, because the user is on a browser without support, or because a print stylesheet flattened the page — that content is permanently invisible. Reveals must degrade to visible.
DevTools Debugging Workflow
Chrome DevTools has first-class tooling for this, and it removes most of the guesswork.
- Open the Animations panel (Command Menu → "Show Animations"). Scroll-driven animations appear there with a scroll icon instead of a clock, and the panel shows the timeline's current progress as a percentage. If your animation does not appear at all, the timeline failed to resolve and the animation is silently inert.
- Check for an inactive timeline. In the Elements panel, an
animation-timelinewhose name resolves to nothing gets no warning by default — but the Styles pane greys out the computedanimationblock. The most common cause is ascroll-timeline-namedeclared on an element that is not actually a scroll container, or a name referenced from outside the declaring element's subtree. - Verify the scroller is scrollable. In the Elements panel, a scroll container is badged
scroll.scroll(nearest)binding to an unexpected ancestor is a frequent surprise; hover the badge to confirm which element the browser considers the scroller. - Scrub with the Animations panel timeline. Dragging the playhead drives a scroll-driven animation the same way it drives a time-based one, so you can inspect any point of the range without touching the scrollbar.
- Confirm compositing in the Performance panel: record a scroll, and look at the "Compositor" track. A correctly built scroll-driven animation shows compositor frames with no accompanying Layout or Recalculate Style entries. Any purple Layout bars mean a keyframe is touching a layout-inducing property.
- Test with the emulated motion preference. Rendering tab → "Emulate CSS media feature prefers-reduced-motion" →
reduce, then scroll the page again and confirm every positional effect is gone.
Browser Compatibility
| Feature | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
animation-timeline with scroll() | 115+ | 26+ | Behind a flag |
animation-timeline with view() | 115+ | 26+ | Behind a flag |
scroll-timeline-name / view-timeline-name | 115+ | 26+ | Behind a flag |
animation-range / animation-range-start / -end | 115+ | 26+ | Behind a flag |
timeline-scope | 116+ | 26+ | Behind a flag |
@supports (animation-timeline: scroll()) | 115+ | 26+ | Reports false while unflagged |
As of mid-2026 Firefox ships the implementation behind layout.css.scroll-driven-animations.enabled, so it must be treated as unsupported for production purposes — the useful consequence is that @supports correctly reports false there, so a properly gated enhancement does the right thing without any browser sniffing. Do not attempt to polyfill this with a script for parity; a JavaScript polyfill reinstates exactly the main-thread cost the feature exists to remove, and the ungated fallback is almost always acceptable. If you need a rule of thumb: treat scroll-driven animation as decoration for roughly three quarters of traffic and design the other quarter's experience first.
Common Pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Element snaps to its end state instantly, no scroll response | animation-timeline unsupported or unresolved, so the animation ran on the document timeline with a 0s duration | Wrap in @supports (animation-timeline: scroll()) and give the base state sane values |
| Motion runs ahead of the finger, then drags behind | The default ease timing function is distorting the scroll-to-progress mapping | Always declare linear; express curvature with keyframe percentage stops |
| Reveal plays forwards on entry and backwards on exit | No animation-range, so the animation spans the full cover range | Set animation-range: entry 0% cover 40% (or similar) so it completes early |
| A card flashes at full opacity before animating | animation-fill-mode is missing, so the from keyframe is not applied before the range starts | Add both to the animation shorthand |
animation-timeline: --name does nothing | The name is declared on an element outside the referencing element's resolution scope | Move the declaration to an ancestor, or hoist it with timeline-scope |
| Effect works on desktop, freezes on mobile | The animated property forces layout, so the compositor cannot run it during a fling | Restrict keyframes to transform, opacity, and filter |
FAQ
What is the difference between scroll() and view()?scroll() tracks how far a scroll container has been scrolled, from its start to its maximum offset. view() tracks one specific element's own passage through that scrollport, so each element gets its own 0 to 100 percent. Use scroll() for document-wide indicators and view() for per-element reveals.
Why does my scroll-driven animation snap instead of interpolating?
Almost always because the animation still has an easing function other than linear. A scroll timeline maps scroll position onto animation progress, and any non-linear timing function then distorts that mapping. Set animation-timing-function to linear and shape the curve with keyframe stops instead.
Do scroll-driven animations run off the main thread?
Yes, when the animated properties are compositor-friendly ones such as transform, opacity, and filter. The timeline itself is evaluated by the compositor, so the effect keeps pace with the scroll gesture even while the main thread is busy. Animating layout-inducing properties forces the work back onto the main thread.
Does animation-duration do anything on a scroll timeline?
No. Progress comes from scroll position, not elapsed time, so the declared duration is ignored for the mapping. It is still worth writing a value because it is what a browser without scroll-timeline support will fall back to when the declaration is not inside an @supports block.
Related
- CSS-Only Micro-Interactions & Animations — the parent guide covering transitions, keyframes, state design, and motion accessibility.
- Scroll progress bar without JavaScript — the smallest complete
scroll(root)build, next to the listener it replaces. - Scroll-triggered reveal animations —
view()reveals compared against IntersectionObserver. - When to use JavaScript animation — where the declarative model genuinely runs out and script is the right tool.
- Feature detection with @supports — the detection discipline these enhancements depend on.
Related articles
More pages in the same section.