css.
css10 min read

Scroll-Driven Animations: A Pure CSS Progress Bar

Scroll-Driven Animations: A Pure CSS Progress Bar

The scroll bar you can actually see

Have you noticed the thin colored line that creeps across the top of nearly every long-form article you read now? Most of those bars are still backed by a hundred lines of JavaScript, and they no longer need to be. A thin colored line sits there, growing wider as you scroll. By the time you reach the footer, the line spans the full width of the viewport. The pattern is so familiar now that most readers don't register it consciously. But the moment it disappears from a long-form piece, they feel slightly adrift, unsure how much reading is left.

For more than a decade, I built that progress indicator the same way everyone else did. Attach a scroll listener to the window. Calculate the ratio of scrollY to scrollHeight - innerHeight. Write the result back to the DOM on every frame. The technique works, but it carries hidden costs I didn't appreciate until I started profiling.

Scroll handlers fire on the main thread, the same thread that handles layout, paint, and user input. A naive implementation re-reads layout properties inside the listener and triggers forced synchronous layout. A slightly less naive one debounces or throttles, trading accuracy for smoothness. Even the best hand-rolled version still pays the cost of running JavaScript on every scroll tick.

Scroll-driven animations let you delete the JavaScript entirely. The browser already knows where the scroll position is. The browser already runs animations on the compositor. Now the two facts can be wired together in CSS without crossing the language boundary. A complete progress bar fits in five or six lines of style, ships zero kilobytes of script, and runs on the compositor thread where it can't block input or block layout.

This intro sketches what the feature is, when it landed in browsers, what shape the syntax takes, and which mental model makes the most sense for picking it up. It's not an exhaustive reference, and it doesn't cover every timeline mode, every edge case around printing or reduced motion, or every workaround for browsers that haven't yet shipped the feature. Treat it as a map of the territory rather than a guidebook to every trail.

What the W3C actually shipped

Why does a feature still labelled 'Working Draft' already work in three of the four major browsers your users are likely to open today? The official name (the CSS Scroll-driven Animations module) sounds heavier than the spec actually is. The specification lives at drafts.csswg.org/scroll-animations-1, and reading the first two sections will give you a clearer picture than any blog post, including this one. The spec defines two new timeline types that animations can attach themselves to, instead of the default document timeline that ticks once per frame.

The first new timeline is the scroll progress timeline. It maps the scroll position of a chosen scroll container to a progress value between 0% and 100%. When the container is scrolled to the top, the timeline reads zero. When the container is scrolled to its maximum extent, the timeline reads one hundred percent. Any animation attached to this timeline advances or rewinds in lockstep with the scroll, and the browser handles the wiring without firing a single JavaScript event.

The second new timeline is the view progress timeline. It tracks the position of a specific element as it moves through the scrollport, from just before the element enters the viewport to just after it leaves. This one is useful for effects like fading an image in as it scrolls into view, or pinning a sidebar while its parent scrolls past. The progress bar use case only needs the first timeline type, so the rest of this article focuses there.

Both timelines are exposed to CSS through two pieces of grammar. The animation-timeline property accepts a timeline name or one of the new functional notations like scroll() and view(). The @keyframes block remains exactly the same as it has been since 2009, which is the friendly part. You write keyframes the way you've always written them, then bind them to a scroll timeline instead of a wall-clock duration, and the browser does the rest.

The simplest possible progress bar

A colleague pasted the example below into her stylesheet, refreshed the page, and then asked me which JavaScript file she still had to import. None. That confusion is the whole point of this section. The element sits fixed at the top of the page, starts at zero width, and grows to full width as the page scrolls.

@keyframes grow-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

#progress {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 4px;
  background: linear-gradient(to right, #06b6d4, #8b5cf6);
  transform-origin: 0 50%;
  animation: grow-progress linear;
  animation-timeline: scroll(root block);
}

That's the entire implementation. The scroll(root block) notation says, in plain English, follow the block-axis scroll of the root element, which on most pages is the document. The keyframes describe a scale from zero to one along the horizontal axis. The browser interpolates between them according to the current scroll position. No JavaScript, no observer, no resize listener to reconcile when the viewport changes size. The browser already knows everything it needs, and the work happens on the compositor.

Read that example carefully and notice what's missing. There's no animation-duration. Duration is meaningless when the timeline is driven by scroll instead of by time. A user who scrolls quickly traverses the keyframes quickly. A user who scrolls slowly traverses them slowly. A user who scrolls back up rewinds them. The animation doesn't have a fixed length in seconds because it doesn't advance in seconds. It advances in pixels of scroll distance, and the keyframes are laid out across that distance from start to finish.

Why scaleX instead of width

The transform property is a deliberate choice rather than a stylistic preference. Animating width from 0 to 100% would produce the same visual result, but it would force the browser to lay out the bar element on every animation tick. Layout is expensive and runs on the main thread. The compositor can't promote a width animation to the GPU, because the surrounding layout might depend on the new width.

Animating transform: scaleX() avoids layout entirely. Transforms are applied during the compositing phase, after layout and paint have already produced their textures. The compositor multiplies a matrix against the existing texture and the result lands on screen without involving the main thread at all. A scroll-driven animation that targets transform or opacity runs at the full frame rate of the display, even while the main thread is busy parsing JSON or hydrating a React tree.

The same advice applies to most modern web animation work, scroll-driven or otherwise. The Chrome team has published guidance on the topic at developer.chrome.com/docs/css-ui/scroll-driven-animations, which goes into the compositor pipeline in more detail and shows DevTools recipes for confirming that an animation is actually running on the compositor rather than thrashing the main thread.

What browsers actually support today

The compatibility story is the part most likely to determine whether you can ship this in production. As of the time of writing, the feature is supported in Chromium-based browsers from version 115 onward, which means Chrome, Edge, Opera, and the various downstream forks. Firefox has the feature behind the layout.css.scroll-driven-animations.enabled flag, and the implementation is progressing but not yet shipped by default. Safari has implemented portions of the spec in Technology Preview builds but hasn't promoted them to a stable release at the time of writing.

The canonical place to check the latest status is the MDN Web Docs page for animation-timeline, which is kept in sync with browser releases and includes a structured browser-compatibility table. You can read it at developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline. The page also documents the syntax variants for named timelines, anonymous timelines, and the scroll() / view() functional notations, with examples that go beyond the bare-bones progress bar shown here.

The correct posture for production use is progressive enhancement. The CSS feature degrades gracefully when the browser doesn't understand it: the @keyframes block is parsed, the animation shorthand is parsed, but without a recognized animation-timeline the animation never advances and the progress bar simply stays at scaleX(0), invisible against the page. A user on an unsupported browser sees the page exactly as it would have looked before the bar was added. No script error fires, no console warning is logged, nothing breaks. You can choose to feature-detect and fall back to a JavaScript implementation, or you can choose to accept that older browsers simply don't get the bar, and either choice is defensible.

The mental shift from time to distance

Spending a few hours building scroll-driven effects reveals a more fundamental shift than the syntax suggests. Web animation has been bound to wall-clock time since the very first setTimeout rotated an image. Animations have durations, easings, delays, iteration counts, all measured in milliseconds. The model has been so dominant for so long that most developers can't picture an animation timeline that isn't made of time.

Scroll-driven animations decouple keyframe progression from time. The keyframes still describe a journey from state A to state B, but the journey is parameterized by something else, in this case the position of a scrollbar. The same primitive could be parameterized by other things in the future: a media element's current playback time, the position of a draggable input, the rotation of a device gyroscope. The W3C has been deliberate about leaving the door open for additional timeline sources, and the syntax was designed to accommodate them without breaking the existing time-based model.

For a progress bar, the mental shift is small. For more ambitious effects, the shift is large and worth dwelling on. Pinning, reveal-on-scroll, parallax, sticky-but-not-really, color-shifting hero sections that respond to where the user is on the page. All of these become declarative problems rather than imperative ones. The browser can optimize a declarative animation in ways it can't optimize an imperative scroll handler, because the browser can see the entire animation at once instead of being told what to do one frame at a time.

Where to go next

The natural follow-ups from here divide into three rough buckets. The first is breadth: working through the rest of the timeline grammar, including named timelines that allow one element's scroll to drive an animation on a sibling, view timelines that respond to element visibility, and the timeline range syntax that scopes keyframes to specific portions of the timeline rather than the whole. The MDN reference and the spec both cover this material in depth and reward careful reading.

The second is robustness: handling prefers-reduced-motion, ensuring the progress bar is hidden during print, choosing a transform-origin that respects right-to-left languages, and confirming that the bar doesn't overlap with sticky headers or modal overlays. None of this is hard, but all of it requires a checklist mindset, and the time to write the checklist is before the feature reaches users rather than after.

The third is graceful degradation: deciding whether to ship a JavaScript fallback for browsers that haven't implemented the feature, and if so, how to detect support cleanly. The standard answer is @supports (animation-timeline: scroll()) for the CSS side and CSS.supports('animation-timeline', 'scroll()') for the JavaScript side, with a small fallback handler attached only when support is missing. This pattern keeps the happy path zero-cost and isolates the legacy code path behind a feature check.

None of those topics fit comfortably in a single intro. The point of this article is to make the shape of the feature visible, show the smallest useful example, and provide pointers into the official documentation for the next layer of detail. The progress bar at the top of a long article isn't a particularly impressive piece of UI on its own, but it's a tidy demonstration of a shift in how web animations can be expressed, and it makes a useful first project for anyone who wants to get a feel for the new model before tackling something more ambitious.