Scroll-driven animations with animation-timeline
Published July 20, 2027
@keyframes grow-progress {
to { transform: scaleX(1); }
}
.scroll-progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
transform-origin: 0 0;
transform: scaleX(0);
background: canvasText;
animation: grow-progress 1ms linear forwards;
animation-timeline: scroll(root block);
}What
animation-timeline: scroll(root block) swaps a CSS animation's driver from the document's clock to the scroll position of a scroller — the animation's 0%/100% keyframes map to the top and bottom of the scroll range instead of to elapsed time.
Why it matters
The classic way to build a scroll-linked effect — a reading-progress bar, a parallax layer, a header that shrinks as you scroll — is a scroll event listener that reads scrollY and writes a CSS custom property or inline style on every tick. That runs on the main thread, competes with layout and paint for the same frame budget, and needs manual requestAnimationFrame throttling to avoid janking under fast scrolls. animation-timeline moves the whole thing onto the compositor: the browser recalculates animation progress from scroll offset without running any JavaScript per frame, so it keeps up at 60fps+ even while the main thread is busy with something else.
How it works
@keyframes grow-progressonly defines atostate (scaleX(1)) — nofromis needed because the element's owntransform: scaleX(0)already establishes the starting point, and a scroll-driven timeline interpolates between whatever0%and100%resolve to.animation: grow-progress 1ms linear forwardsstill needs a nonzero duration even though real time never elapses here — the shorthand's duration token is what tells the browser "this animation has a timeline-relative range to fill."1msis a convention for scroll-driven animations: it's functionally irrelevant onceanimation-timelineoverrides what drives progress, but a0sduration causes some engines to skip the animation entirely.animation-timeline: scroll(root block)creates the timeline:rootscopes it to the document's own scrolling element (the layout viewport) rather than the nearest scrolling ancestor, andblocktracks the block-axis (vertical, in a horizontal writing mode) scroll offset.position: fixedplustransform-origin: 0 0keeps the bar pinned to the top of the viewport and scaling from its left edge asscaleXgrows from0to1.
Gotchas
- Firefox does not support this in stable yet. As of this writing it still sits behind the
layout.css.scroll-driven-animations.enabledflag (on by default only in Nightly), despite being a named Interop 2026 priority — don't ship this as your only progress indicator without a fallback or a feature check. - Chrome and Edge have supported
scroll()/view()timelines unflagged since Chrome 115 (July 2023); Safari shipped support in Safari 26 (September 2025, alongside Apple's platform-wide version renumbering). - Feature-detect with
CSS.supports("animation-timeline: scroll()")and fall back to a static bar or a throttled scroll listener when it returnsfalse. - Always pair scroll-driven animation with a
prefers-reduced-motionoverride —@media (prefers-reduced-motion: reduce) { .scroll-progress { animation-timeline: none; } }— the same as you would for any other motion. scroll()tracks a scroll position;view()(not used here) tracks an element's visibility as it crosses the scrollport — reach forview()instead when the effect should trigger as a specific element enters or leaves view, not as the whole page scrolls.
Related
view()— the sibling function for view-progress timelines, driven by one element's visibility rather than a scroller's overall position.IntersectionObserver— the JS-driven equivalent for view-based triggers; still necessary when the response to visibility needs to run actual logic, not just interpolate a CSS property.