Building a Scroll Progress Bar with scroll-timeline

Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.

The problem

A reading-progress bar is the smallest useful scroll-linked component and the one most often built badly. The traditional implementation attaches a scroll listener, reads scrollTop and scrollHeight, computes a ratio, and writes a width — four main-thread operations on every scroll event, two of which force layout.

On a fast desktop it looks fine. On a mid-tier phone the bar lags the scroll thumb by a visible margin, because the handler is queued behind whatever else the main thread is doing and the width change re-runs layout for the whole document.

Root cause: the value and the property are both wrong

Two independent mistakes compound here.

The first is the source of the value. A scroll handler samples scroll position from the main thread, which means the value is always at least one frame stale and gets staler under load. A scroll timeline reads the same position on the compositor, where the scroll is actually happening, so progress and pixels are computed together.

The second is the animated property. width is layout-tier: changing it runs layout, paint and composite. Even with a perfect value source, a width-driven bar cannot stay on the compositor. transform: scaleX() with a left-hand transform-origin produces an identical visual result and touches nothing but the layer’s matrix.

Fix both and the component has no JavaScript at all. The remaining question is purely structural: can the bar see the timeline it needs?

Cost of one scroll frame, by implementationThe listener version pays for the value and for the property; the timeline version pays for neither.Cost of one scroll frame, by implementationframe budget 16.7 msscroll listener + widthhandlerforced layoutpaint12.8 msscroll listener + transformhandler4.7 msscroll-timeline + transform0.9 mshandlerforced layoutpaintcompositestyle
The listener version pays for the value and for the property; the timeline version pays for neither.

Step-by-step resolution

Building the barThe whole component is one source declaration, one scope declaration and one consumer.Building the bar1Decide what progress means: the whole document, or one article's passagethrough the viewport.Document scroll uses scroll(), an article uses view()2Publish the timeline on the scroll source with scroll-timeline-name.Visible to descendants only, so far3Hoist it with timeline-scope on an ancestor of both the source and thebar.The fixed bar can now resolve the name4Bind the bar with animation-timeline, animation-duration: auto and lineareasing.Progress tracks the scrollbar exactly5Add the reduced-motion branch that severs the timeline and shows a restingstate.
The whole component is one source declaration, one scope declaration and one consumer.

Production code pattern

/* Intent: a reading-progress bar fixed to the top of the viewport, driven by
   the document scroller, with no JavaScript and no per-frame main-thread work. */

/* The common ancestor hoists the name so a non-descendant can subscribe. */
:root {
  timeline-scope: --page-scroll;
}

/* The source publishes the timeline. */
body {
  scroll-timeline: --page-scroll block;
}

/* The consumer: a sibling of the content, not a descendant of the scroller. */
.progress-bar {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  block-size: 3px;
  background: linear-gradient(90deg, #7c3aed, #f97316);

  /* scaleX from the leading edge: composite-only, no layout, no paint. */
  transform-origin: left center;
  transform: scaleX(0);

  animation: fill-progress linear both;
  animation-timeline: --page-scroll;
  animation-duration: auto;    /* required: progress comes from scroll */
  will-change: transform;
}

@keyframes fill-progress {
  to { transform: scaleX(1); }
}

/* Accessibility gate: the bar is decorative here, so it rests hidden rather
   than tracking scroll. Where it is genuinely informative, show it complete. */
@media (prefers-reduced-motion: reduce) {
  .progress-bar {
    animation: none;
    animation-timeline: auto;
    transform: scaleX(0);
    opacity: 0;
  }
}

/* Progressive enhancement: without scroll-timeline support the bar stays at
   scaleX(0) and is simply not shown, rather than being stuck at full width. */
@supports not (animation-timeline: scroll()) {
  .progress-bar { display: none; }
}

Rendering Impact: transform — composite only. The timeline is resolved once during style; each frame is a compositor-side progress read and a matrix update, with the main thread untouched.

The @supports block is doing real work. Without it, a browser that ignores animation-timeline also ignores the animation, leaving the bar at its scaleX(0) base state — a permanently empty progress bar, which looks broken rather than absent. Hiding it is the honest fallback.

Which timeline the bar should subscribe toThe answer depends entirely on what 'progress' means for this page.Which timeline the bar should subscribe toTimelineReaches 100% whenWhole document readscroll-timeline on bodyThe document is scrolled to theendOne article inside alonger pageview-timeline on the articleThe article has fully passed theviewportA scrollable panelscroll-timeline on the panelThe panel is scrolled to its endBar inside thescrollerAnonymous scroll()No name or scope neededBar fixed outsidethe scrollerNamed + timeline-scopeThe name must be hoisted
The answer depends entirely on what 'progress' means for this page.

Verification checklist

Constraints and trade-offs

  • timeline-scope must be on an ancestor of both the source and the consumer; putting it on the source itself silently fails.
  • Scaling a gradient stretches it, so a gradient bar’s colour ramp compresses at low progress; use a solid fill if that matters.
  • A progress bar is decorative for most content, which means it is a candidate for removal rather than adaptation under reduced motion.
  • will-change: transform on a permanently visible bar holds a small texture for the life of the page — acceptable here, but not a pattern to repeat per element.
  • The bar reports scroll position, not reading position; on pages with a long footer it will report progress the reader does not feel.

Frequently asked questions

Why scaleX instead of width?

Width is a layout property: every frame of a width animation runs layout and paint on the main thread, and it also shifts anything laid out next to the bar. scaleX is compositor-only and changes nothing about the box, which is why a transform-based bar holds 60 fps while a width-based one does not.

Do I need a named timeline for a document-level progress bar?

Only if the bar is not a descendant of the scroller. A bar positioned fixed at the top of the page is usually not inside the scrolling element’s subtree in the way anonymous resolution requires, so publishing a name and hoisting it with timeline-scope is the reliable route.

How do I show progress for a specific article rather than the whole page?

Put the scroll-timeline on the article’s own overflow container if it scrolls, or use a view timeline on the article element so progress tracks how much of it has passed through the viewport.


Making the bar meaningful to assistive technology

A progress bar that only exists visually is decoration, and decoration should be hidden from assistive technology rather than announced as an unlabelled graphic. That is the correct default for the reading-progress case: aria-hidden="true" on the element, no role, no label.

Where the bar is genuinely informative — a multi-step form’s completion, an upload’s progress — the visual technique above is the same but the semantics are not. The element needs role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax, and those values have to be updated by script, because a scroll timeline animates presentation without notifying anything.

That is the honest limitation of a pure-CSS progress bar: it can show progress, but it cannot report it. Pairing the two is straightforward — the timeline drives the visual, and a throttled scroll listener or an IntersectionObserver on section boundaries updates aria-valuenow a few times rather than sixty times a second. The announcement does not need per-pixel accuracy; a value updated at each section boundary is more useful to a screen reader user than a continuously changing number would be.

Under reduced motion the bar in the pattern above is hidden entirely, which is right for a decorative reading indicator. For an informative one, keep it visible and static, and let the value updates carry the information without the sweep.