All work
Snippet · 2024
CSS · 6 lines
22 Mar 2024
CSS craft

Scroll-Driven Progress Bar

Six lines of CSS. No scroll listener, no layout thrash. The bar at the top of this page is the snippet running — scroll and watch it.

Your browser doesn’t support animation-timeline: scroll(), so the bars on this page are static. The code below still reads the same.

The snippet

progress.csscomplete
@keyframes grow { from { transform: scaleX(0); } }

.progress {
  position: fixed; inset: 0 0 auto; height: 3px;
  background: CanvasText;
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: scroll(root block);
}

One <div class="progress"> anywhere in the document. It does not matter where — position: fixed takes it out of flow and the timeline is the document’s, not the element’s.

Line by line

01
Only a from keyframe
The to state is whatever the element already is, so it can be left out. Writing to { transform: scaleX(1) } is the same thing spelled longer.
02
scaleX, not width
Animating width means layout on every frame. A transform is composited, so the bar can move on its own thread while the main thread is busy. transform-origin: 0 50% is what makes it grow from the left instead of the centre.
03
No animation-duration
Scroll position replaces time as the driver: 0% scrolled is the from keyframe, 100% is the to. linear keeps the bar honest — an eased curve would report the wrong position.
04
scroll(root block)
Which scroller, which axis. root is the document; block is the vertical axis in a horizontal writing mode. The default is scroll(nearest block), which is a different bar the moment your page sits inside a scroll container.

Scoped to a container

The bar does not have to track the page. Name a timeline on any scroll container and any element can consume it, in or out of that container. Scroll the panel below.

live · named timeline

A named timeline is two declarations. The container publishes one with scroll-timeline: --panel block, and the bar subscribes with animation-timeline: --panel. Three, if the bar sits outside the scroller.

The bar above this text is not inside the scroller — it is a preceding sibling, which by default cannot see the name. timeline-scope: --panel on the wrapper lifts the name to the whole subtree and both find it.

This is the version to reach for in an app shell, where the page chrome is fixed and the article scrolls in a pane. scroll(root) would report nothing there, because the document itself never scrolls.

Keep scrolling. The bar tracks this box, and the bar at the top of the window keeps tracking the window, independently.

Reaching the bottom of this panel puts the blue bar at full width while the page bar sits wherever you left it.

panel.css
.panel {
  timeline-scope: --panel;  /* name visible to the subtree */
}

.panel__scroller {
  overflow-y: auto;
  scroll-timeline: --panel block;
}

.panel__bar {
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: --panel;
}

What it replaces

The JavaScript equivalent is not long either, and that is the point — it is short and still wrong in three ways.

the version most people shipdon’t
addEventListener('scroll', () => {
  // three forced layout reads, every scroll event
  const max = document.body.scrollHeight - innerHeight;
  bar.style.transform = `scaleX(${scrollY / max}`)`;
});
Scroll listener
Scroll timeline
Runs on
Main thread, behind whatever else is queued
Compositor, in step with the scroll itself
Layout cost
scrollHeight forces a reflow per event
None. The browser already knows the range
Stays correct
Until content resizes and the cached max goes stale
Range is re-derived by the engine
Teardown
Yours to remember
Dies with the element

Before you ship it

01
Support is uneven, and the fallback is free
Chromium has shipped it since 115 in 2023 and Safari followed in 2025; Firefox is still behind a flag at the time of writing, so check caniuse before you rely on it. Where the timeline is unknown the animation never applies, which leaves the element at its natural size — a full-width bar. Give it a start state and it degrades to nothing instead.
@supports not (animation-timeline: scroll()) {
  .progress { display: none; }
}
02
Firefox wants a duration anyway
With the flag on, Firefox will not apply the animation without an animation-duration. The value is ignored on a scroll timeline, so animation-duration: 1ms is a harmless line to carry.
03
A progress bar is not motion
Leave it out of prefers-reduced-motion. It reports where you are and follows your finger exactly; there is no unrequested movement to suppress. Parallax built the same way is a different question.
04
Don't announce it
It is decoration for a position screen readers already report. No role="progressbar", no live region, no aria-valuenow you would then have to keep updated in JavaScript — which would put back the listener this snippet exists to remove.

The interesting part is not the bar. It is that scroll position is now a value CSS can read, so a whole category of effect that used to require a listener, a cached measurement, and a teardown path is a property you set on the element that moves.

Reference
MDN · spec
Support
Check before shipping
Romulo Nascimento · CSS craft · 2024
Back to all work