Scroll down. The amber bar at the top fills as you go.
animation-timeline: scroll().
Try Chrome 115+, Firefox 126+ (with flag), or Safari 18.2+.
Long-form content tells you how much you have read — but only if you know where the bottom is. Scrolling to estimate remaining length is an unconscious mental calculation that every reader performs, and every reader gets wrong.
A reading progress bar eliminates that estimation by mapping scroll position to visual fill. The bar starts empty at the top of the page and reaches full width at the bottom. The feedback is instant, visual, and requires zero cognitive effort to interpret.
Before animation-timeline, a reading progress bar required a scroll event listener, a getBoundingClientRect call on every frame, and a manual update of the bar's width or transform. The JavaScript version runs on the main thread, competes with rendering for frame budget, and triggers layout thrashing on every scroll event.
The CSS version runs on the compositor thread with zero JavaScript execution. The bar updates at the compositor's refresh rate, independent of main-thread load. On a mid-range mobile device, the difference between a scroll-listener progress bar and a CSS scroll-driven one is the difference between jank and smooth scrolling.
A reading progress bar is a decorative enhancement. If the bar is invisible, the reader can still read the content. This is why the progressive enhancement pattern matters: default to no animation, layer the bar on top only when the browser supports it and the user has not requested reduced motion.
Do not use a reading progress bar on pages shorter than one viewport height — there is nothing to track. Do not use it on pages where scroll position is programmatically controlled (carousels, single-page apps with virtual scrolling).
The bar uses transform: scaleX() with transform-origin: left center to fill from left to right. Using transform instead of width keeps the animation on the compositor thread. Width changes trigger layout; transform changes do not.
The scroll(root) value tracks the document's scroll position. For containers other than the root, pass a selector or use scroll(nearest) for the closest scrollable ancestor.
Position is fixed so the bar stays visible at the top of the viewport regardless of scroll state. The z-index ensures it renders above page content.
Instead of a flat bar, you can use a gradient, a dashed pattern, or a two-tone indicator that changes color at 50%. The animation keyframes remain the same — only the visual styling differs. For a bar that fills right-to-left (for RTL layouts), use transform-origin: right center.
For a bar that sits inside the page rather than fixed to the viewport, use position: sticky instead of position: fixed. The animation timeline remains unchanged.
The bar is decorative. It does not convey information essential to understanding the content. The prefers-reduced-motion: reduce media query hides it entirely — users who do not want motion effects see a clean, bar-free interface.
No ARIA attributes are needed. The bar is a visual affordance, not a functional element. Screen readers ignore it.