Scroll Progress

A reading bar that tracks scroll position without a listener.

← All motions

Scroll Progress

This is the cheapest scroll-driven effect there is, and almost every implementation does it the expensive way: a scroll listener that reads scrollTop, writes width, and forces the browser to recalculate layout on every frame. The CSS version binds the same value to a transform and never touches the main thread.

trigger: scroll animation-timeline: scroll() transform: scaleX

Live demo

Scroll this panel

The bar at the top of this box is not measuring the page. It is bound to this element's own scroll container, using animation-timeline: scroll(nearest block). Scroll to the bottom and it reaches exactly full width at the last pixel.

Why scaleX and not width

Animating width changes the geometry of the element, so the browser re-runs layout for the bar and everything that depends on it. Animating transform: scaleX() leaves geometry untouched and runs on the compositor.

One consequence: a scaled border or radius distorts. A progress bar has neither, which is exactly why the substitution is safe here and unsafe on, say, a card.

Where the number comes from

The timeline maps scroll offset to progress with no rounding you have to manage. When the container has no scrollable overflow the animation sits at its start value, so a short article simply shows an empty bar rather than a full one.

That behaviour is worth keeping. A full bar on an unscrollable page implies the reader has finished something they never started.

The fallback is one condition

Firefox has supported this since version 144; anything older needs the JavaScript path. The check is a single CSS.supports call, and the fallback reads scroll position inside a requestAnimationFrame callback rather than on the event itself.

Reading layout inside a scroll handler is what makes the naive version stutter: the handler runs many times per frame, each read forces a style recalculation, and the writes queue up behind it.

End of the panel

The bar should now be at one hundred percent. Scroll back up and it unwinds in reverse — there is no state to reset, because the animation is a pure function of scroll offset.

The prompt

Create a scroll progress bar for a long article.

Structure:
- The bar sits inside the scroll container, as a sticky element at the top, so that its nearest scroll ancestor is the container it is measuring.
- The bar itself is a thin element, 3 to 4px tall, with transform-origin: 0 50%.

Motion:
- Bind the animation to scroll position, not to time: animation-timeline: scroll(nearest block), with animation-name pointing at a keyframe that goes from scaleX(0) to scaleX(1).
- Do NOT use animation-duration. With a scroll timeline the duration is ignored; the scroll range is the clock.
- Use scaleX, never width. Width forces layout on every frame; scaleX runs on the compositor.
- Never scale an element that has a border-radius or a border. Both distort.

Fallback:
- Detect support with CSS.supports('animation-timeline', 'scroll()').
- If unsupported, compute scrollTop / (scrollHeight - clientHeight) inside a requestAnimationFrame callback triggered by a passive scroll listener. Never read layout directly in the scroll handler.
- Clamp the result to 0..1 and set transform, not width.

Accessibility and restraint:
- The bar is decorative: give it aria-hidden="true" and do not announce a percentage.
- Under prefers-reduced-motion, keep the bar but let it update without easing. Removing it entirely is also acceptable; a jumping bar is not.
- Do not colour it as an alert. It is a position indicator, not a warning.

Constraints:
- Position it above the content with a solid background, or long text will show through it.
- It must reach exactly 100% at the last pixel of scroll, not before, and never exceed it.

What actually makes it work

Compatibility and performance

AspectDetail
animation-timeline: scroll()Chrome 115+, Edge 115+, Safari 26+, Firefox 144+. Older Firefox and Safari need the fallback.
CostOne composited transform. No layout, no paint, no listener on the main thread.
Fallback costOne rAF callback per frame while scrolling, one layout read and one style write. Acceptable.
Resizescroll(nearest) re-measures automatically; a JS fallback must recompute the scroll range on resize.

One practical note: on very long pages the bar becomes a decoration rather than information — nobody reads a percentage that moves by a pixel per screen. It earns its place on articles between roughly 1,500 and 8,000 words, and on multi-step forms.

When not to use it

Don't put it on a short page. If the article fits in two screens, the bar spends most of its life at 90 percent, which teaches visitors the indicator is unreliable.

Don't use it as the only progress cue in a multi-step form. A form needs named steps; a slim bar tells you how far but not what is left.

Don't animate its colour. A bar that shifts from green to red as you scroll reads as a warning about the content rather than a reading aid.