Skip to main content
Martin.

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:

  1. Performance. transform runs on the compositor thread. width triggers layout recalculations on every frame. For something that updates constantly during scroll, this matters.
  2. Sub-pixel rendering. scaleX produces 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.