A Reading Progress Bar in Pure CSS (No JavaScript Required)
Every reading progress bar tutorial I've seen follows the same script. Listen to the scroll event. Calculate the percentage. Update a CSS variable or inline width. Debounce it because you're running code sixty times a second for a cosmetic detail.
Here's the one on this site:
@keyframes reading-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@supports (animation-timeline: scroll()) {
.reading-progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--color-accent);
transform-origin: left;
z-index: 50;
animation: reading-progress linear;
animation-timeline: scroll(root);
}
}That's the entire thing. No JavaScript. No scroll listener. No requestAnimationFrame. No debouncing. The browser handles all of it, on the compositor thread, at native performance.
How It Works
The key is two lines:
animation: reading-progress linear;
animation-timeline: scroll(root);Normally, an animation plays over time — you give it a duration and it runs from start to finish. animation-timeline: scroll() replaces time with scroll position. Instead of "play this over 300ms", it means "play this over the entire scroll range".
At the top of the page, the animation is at 0% — scaleX(0). At the bottom, it's at 100% — scaleX(1). The browser interpolates everything in between based on how far you've scrolled. No JavaScript needed because the browser already knows the scroll position — it's just mapping it to an animation progress value.
The scroll(root) argument tells it to track the root scrolling element (the page itself, not some overflow container). If you had a scrollable div, you could use scroll(nearest) or scroll(self) instead.
Why scaleX Instead of width
You might wonder why I'm animating transform: scaleX() instead of just width: 0% to width: 100%. Two reasons:
- Performance.
transformruns on the compositor thread.widthtriggers layout recalculations on every frame. For something that updates constantly during scroll, this matters. - Sub-pixel rendering.
scaleXproduces smoother intermediate values than percentage widths, which can snap to pixel boundaries and create a stuttery feel.
The transform-origin: left is essential — without it, the bar would scale from the centre outward instead of growing from left to right.
The @supports Guard
The whole thing is wrapped in @supports (animation-timeline: scroll()):
@supports (animation-timeline: scroll()) {
/* ... */
}This is progressive enhancement in its purest form. Browsers that support scroll-driven animations get the progress bar. Browsers that don't — currently just Firefox — simply don't render it. No broken layout, no errors, no fallback JavaScript. The feature is nice-to-have, not load-bearing, so graceful absence is the right strategy.
If this were something critical, I'd add a JS fallback inside the @supports negation. But a reading progress bar? If 8% of your users don't see it, nobody's filing a support ticket.
The Component
On the React side, it's almost embarrassingly simple:
export default function ReadingProgressBar() {
return <div className="reading-progress-bar" aria-hidden="true" />
}Drop it at the top of your article layout. The aria-hidden="true" is important — this is a visual decoration, not content. Screen readers shouldn't announce "progress bar at 47%." every time focus moves.
What About prefers-reduced-motion?
Interestingly, I don't explicitly handle it here. The bar doesn't move in the traditional sense — there's no animation playing over time, no element translating across the screen. The bar's width is purely a function of scroll position, which the user is already controlling directly. It's more like a scrollbar than an animation.
If you disagree with that judgement, the fix is one line:
@media (prefers-reduced-motion: reduce) {
.reading-progress-bar {
display: none;
}
}The Scroll-Driven Animations Spec
animation-timeline: scroll() is part of a much larger specification called Scroll-Driven Animations. It also includes view() timelines, which trigger animations based on when an element enters and exits the viewport — useful for scroll-triggered reveals without Intersection Observer.
The spec is well-designed. It reuses the existing @keyframes syntax you already know, and just swaps the timeline from time to scroll position. You don't need to learn a new animation system; you just need to learn one new property.
Browser Support
Chrome 115+, Edge 115+, Safari 18.4+ (behind a flag in earlier versions). Firefox is the notable holdout — it's in development but not shipped yet. That @supports guard handles this cleanly.
The Point
The best CSS features are the ones that delete JavaScript. Scroll-driven animations delete an entire category of scroll listeners that exist solely to map scroll position to visual state. The browser was already doing this calculation internally — now it just exposes it to CSS.
A reading progress bar is maybe the simplest possible use case. But it demonstrates the principle: if the browser already knows the value, don't calculate it in JavaScript and pass it back. Let CSS read it directly.
Four properties. Zero JavaScript. Native performance. Sometimes the best code is the code you delete.