A Reading Progress Bar Driven by scroll(root)

A reading-progress bar is the smallest useful scroll-linked effect: a strip across the top of the page whose fill maps directly onto how far down the document the reader has travelled. The narrow problem this page solves is producing that bar with no script at all — one element, one @keyframes rule, and animation-timeline: scroll(root) — and doing it in a way that stays glued to the scrollbar during a fast flick, which is precisely where the traditional listener implementation falls apart. This is the simplest entry point into scroll-driven animations, because the timeline you need is the one the browser already maintains for the document.


Why Not the Listener Version

Here is the implementation this page replaces, in its most common form:

// The approach being replaced.
const bar = document.querySelector('.progress');

window.addEventListener('scroll', () => {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  const pct = window.scrollY / max;
  bar.style.transform = `scaleX(${pct})`;
});

It is eight lines and it works. It also has four structural problems that no amount of tuning removes.

It runs on the main thread. Scroll input is processed by the compositor, but a scroll event is dispatched to the main thread, so the bar's position is computed after the scroll has already been painted. The bar is permanently at least one frame behind the content, and during any long task — hydration, a big JSON parse, an ad script — it stops updating entirely while the page keeps scrolling. On a fast flick this reads as the bar visibly chasing the page.

It reads layout every frame. scrollHeight and innerHeight are layout-dependent, so unless you cache them you have written a forced synchronous layout into the hottest callback on the page. Caching them, in turn, means you now have to invalidate the cache on resize, on font load, and on any DOM mutation that changes document height.

It is bytes and a lifecycle. The listener must be registered, and in a component framework it must also be removed, and if the page is server-rendered the bar is wrong until hydration finishes.

And it does nothing useful when JavaScript fails. The bar sits at zero, which is worse than no bar at all, because it actively communicates false information.

The CSS version has none of these properties. The timeline is evaluated where the scroll offset already lives, so the bar cannot lag; there is no layout read, because the browser is not measuring anything it did not already know; there is no lifecycle; and when the feature is unsupported the declaration is simply not applied, which is a state you get to design.

Listener path versus scroll timeline path Left column lists four main-thread steps for a scroll event listener; right column lists the equivalent compositor steps for an animation bound to a scroll progress timeline. Two ways to draw a progress bar scroll event listener animation-timeline: scroll() scroll event dispatched read scrollY and height write inline transform one frame behind compositor scrolls timeline sampled there scaleX applied same frame as the scroll The right-hand path never touches the main thread.

Complete Working Implementation

Scroll the frame below. The bar is bound to the frame document's own scroller, so it fills as you go and empties as you come back — no listener involved.

Live demoReading progress bar, no JavaScript
Scroll the frame — the bar at the top tracks how far through the document you are, driven entirely by scroll(root).
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reading progress</title>
<style>
  body { margin: 0; font: 16px/1.6 system-ui, sans-serif; }

  .progress {
    position: fixed;
    inset-block-start: 0;
    inset-inline: 0;
    z-index: 3;
    height: 5px;
    background: #4361c8;
    /* Scale from the left edge, so scaleX(0.4) fills the first 40%. */
    transform-origin: 0 50%;
    /* `linear` is mandatory: any easing curve would distort the mapping
       from scroll position onto animation progress. */
    animation: grow-progress linear;
    /* `root` = the document's own scroller (not the nearest ancestor).
       `block` = the document's block axis, vertical in this writing mode. */
    animation-timeline: scroll(root block);
  }

  @keyframes grow-progress {
    from { transform: scaleX(0); }
    to   { transform: scaleX(1); }
  }

  /* The bar carries position information, so it must never disappear.
     Under reduced motion we keep it and drop only the decorative colour
     travel; the fill itself is not a vestibular risk at 5px tall. */
  @media (prefers-reduced-motion: reduce) {
    .progress { background: #4361c8; }
  }

  .article { max-width: 40rem; margin: 0 auto; padding: 2rem 1.25rem 60vh; }
</style>
</head>
<body>
  <!-- role="presentation": the bar duplicates information the scrollbar
       already exposes to assistive technology. Do not announce it twice. -->
  <div class="progress" role="presentation"></div>

  <article class="article">
    <h1>The Compositor Never Blinks</h1>
    <p>Scroll this document and watch the bar at the top.</p>
    <p>Its progress is not being computed by any script.</p>
    <p>It is a keyframe animation whose timeline is the scroller itself.</p>
    <p>Because the compositor already owns the scroll offset, sampling that
       timeline costs nothing on the main thread.</p>
    <p>Block the main thread with a long task and the bar keeps pace.</p>
    <p>A scroll listener would freeze.</p>
  </article>
</body>
</html>

The whole effect is four declarations on .progress, and only two of them are new: animation-timeline and the linear in the shorthand.


The Key Technique: A Timeline With No Duration

The move that makes this work is that animation-timeline: scroll(root block) replaces the animation's source of progress. On the default document timeline, progress is elapsed / duration. On a scroll progress timeline, progress is currentScrollOffset / maxScrollOffset, evaluated fresh in whatever frame the scroll is applied. animation-duration becomes meaningless — you can declare 4s or omit it entirely and the bar behaves identically — because nothing is measuring time.

That reframing is why the bar cannot fall behind. There is no callback to be scheduled and no value to be pushed; there is a function of scroll offset that the compositor evaluates while it is already handling the scroll. Progress and paint happen in the same frame, by construction.

The corollary is the one thing that trips people up: an easing function is now a distortion, not a flourish. animation-timing-function: ease remaps progress non-linearly, so at 50% scroll the bar would read something other than 50% — the bar would race ahead early and crawl at the end. Always write linear. If you want a non-uniform bar (a fill that accelerates once the reader is past the introduction, say), express that with keyframe stops, which are honest about being a deliberate mapping:

@keyframes grow-progress {
  from            { transform: scaleX(0); }
  30%             { transform: scaleX(0.15); }
  to              { transform: scaleX(1); }
}

Variation: A Bar Scoped to the Article, Not the Document

scroll(root) measures the whole document, so the bar hits 100% only at the very bottom of the page — past the footer, the related links, and the comment form. Readers experience that as a bar that never quite finishes. To measure the article instead, name a view timeline on the article element and bind the bar to that name.

.article {
  /* The article's own pass through the scrollport becomes a named timeline. */
  view-timeline-name: --article-pass;
  view-timeline-axis: block;
}

.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 5px;
  background: #4361c8;
  transform-origin: 0 50%;
  animation: grow-progress linear;
  /* Reference the name instead of an anonymous scroll() timeline. */
  animation-timeline: --article-pass;
  /* Start when the article's top edge reaches the top of the scrollport;
     finish when its bottom edge does. This is the "reading" window. */
  animation-range: contain 0% contain 100%;
}

The contain range is the important half of this variation. It spans the period during which the article fully occupies the scrollport, so the bar reads 0% exactly when the article's first line reaches the top and 100% exactly when its last line does — which is what "progress through the article" actually means to a reader. Because .progress is a fixed-position element outside .article, the name must be visible to it; if the two are not in the same subtree, hoist the name with timeline-scope: --article-pass on their common ancestor.


Browser Support

animation-timeline with the scroll() function shipped in Chrome and Edge 115 and in Safari 26; Firefox has the implementation but keeps it behind a preference as of mid-2026, so treat it as unsupported. The graceful outcome without a guard is that the bar sits at scaleX(1), permanently full, because the animation runs on the document timeline with a zero duration and jumps straight to the to keyframe — misleading rather than merely absent. Guard it so the failure is a bar that never appears:

.progress { display: none; }

@supports (animation-timeline: scroll()) {
  .progress {
    display: block;
    animation: grow-progress linear;
    animation-timeline: scroll(root block);
  }
}

Do not reach for a JavaScript fallback that reproduces the effect for Firefox. Reinstating the listener puts back every cost this page exists to avoid, for a decorative strip; the full argument for choosing between enhancement and parity is in scroll-driven animation fallbacks.


FAQ

Why animate scaleX instead of width for a progress bar? Width changes force the browser to lay the element out again on every frame, which pulls the work back onto the main thread. A scaleX transform is handled entirely by the compositor, so the bar can keep pace with a fast scroll.

What does scroll(root block) mean exactly? The first keyword picks the scroll container: root means the document's viewport scroller rather than the nearest ancestor scroller. The second picks the axis: block is the document's block direction, which is vertical in a horizontal writing mode.

Should a progress bar be hidden under prefers-reduced-motion? No. The bar conveys position information, so removing it removes information. Keep it visible and keep it updating, and only drop any decorative extras such as a colour shift or a glow that travels along it.

Does the bar reach 100 percent at the last paragraph? No, it reaches 100 percent at the scroller's maximum scroll offset, which is after the footer. If you want it full at the end of the article, put the article in its own scroll container or accept the offset.


Related articles

More pages in the same section.