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?
Step-by-step resolution
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.
Verification checklist
Constraints and trade-offs
timeline-scopemust 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: transformon 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.
Related
- Scroll-Driven Animation Patterns — the parent guide covering
scroll()andview()in full - Scroll Timeline Scoping & Ranges — how
timeline-scoperesolves a name across subtrees - Named vs Anonymous Scroll Timelines — deciding whether this bar needs a name at all