Building Scroll Progress Indicators
Reading progress bars, section trackers, and chapter completion rings are some of the most requested UI patterns in long-form editorial and documentation sites. Before CSS Scroll-Driven Animations, every implementation required a scroll event listener on the main thread, a requestAnimationFrame loop, and careful debouncing to stay inside the 16 ms frame budget enforced by the compositor. The shipped Level 1 spec β available in Chrome 115+ and Safari 17.4+ β moves all of this to the compositor, producing scroll-linked motion with zero JavaScript and zero layout overhead during scroll.
This section of the Scroll-Driven & View Transition Implementation Patterns guide covers every production-ready approach: document-level bars, scoped section trackers, radial ring indicators, and multi-indicator dashboards.
Pages in This Section
- Creating a Reading Progress Bar with scroll-timeline β Pure-CSS implementation, React/Vue integration gotchas, and View Transition sync
Syntax Reference
The scroll progress indicator pattern rests on three CSS properties from the CSS Scroll-Timeline API:
/* Canonical property signatures */
/* 1. Anonymous scroll timeline β maps the scroll container's block axis */
animation-timeline: scroll( <scroller>? <axis>? );
/* <scroller>: nearest | root | self default: nearest */
/* <axis>: block | inline | x | y default: block */
/* 2. Named scroll timeline β defined on the scroll container */
scroll-timeline-name: --my-timeline; /* <custom-ident> */
scroll-timeline-axis: block; /* block | inline | x | y */
/* Shorthand: */
scroll-timeline: --my-timeline block;
/* 3. Scope the active range within the timeline */
animation-range: <range-start> <range-end>;
/* keywords: normal | entry | exit | cover | contain */
/* each takes an optional length-percentage offset */
Removed syntax (do not use): The @scroll-timeline at-rule with source: and scroll-offsets: descriptors was stripped from the spec before browsers shipped. All production code must use the scroll() function form above.
Key defaults:
| Property | Default value | Effect |
|---|---|---|
animation-timeline |
auto (uses document time) |
Must be set explicitly |
animation-range |
normal (full scroll extent) |
Tracks 0 % β 100 % of the scroller |
animation-fill-mode |
none |
Set both to hold start/end states |
scroll-timeline-axis |
block |
Vertical scroll in horizontal writing modes |
Minimal Working Example
A document-level reading progress bar requires no extra HTML beyond one fixed element:
<!DOCTYPE html>
<html lang="en">
<head>
<style>
/* Progress track */
.reading-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
transform-origin: left center;
transform: scaleX(0); /* start collapsed */
background: oklch(60% 0.18 280); /* matches site accent */
/* Scroll-driven animation */
animation: grow-bar linear both;
animation-timeline: scroll(root block);
}
@keyframes grow-bar {
to { transform: scaleX(1); }
}
</style>
</head>
<body>
<div class="reading-bar" role="progressbar"
aria-label="Reading progress" aria-valuenow="0"
aria-valuemin="0" aria-valuemax="100"></div>
<!-- long article content here -->
</body>
</html>
Why transform: scaleX() not width: Animating width forces a full layout recalculation on every frame, pulling execution back to the main thread. scaleX is a composite-only operation β it stays entirely on the GPU layer and never triggers a layout or paint pass.
animation-range and Timeline Scoping
animation-range controls which portion of the scroll range drives the animation. For a full document tracker the default normal (0 % β 100 %) is correct. For section indicators, narrow the range using keyword + percentage pairs.
Scoped section tracker
/* The scroll container for the article body */
.article-body {
scroll-timeline-name: --article-progress;
scroll-timeline-axis: block;
overflow-y: auto;
}
/* The tracker element (lives inside .article-body) */
.section-bar {
position: sticky;
top: 0;
height: 3px;
transform-origin: left center;
transform: scaleX(0);
background: currentColor;
animation: fill-bar linear both;
animation-timeline: --article-progress;
/* Start at 10 % in (past the header), finish at 90 % */
animation-range: entry 10% cover 90%;
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
animation-range keyword reference
| Keyword | Meaning for a scroll indicator |
|---|---|
entry |
When the tracked element starts entering the scroller |
exit |
When the tracked element starts leaving the scroller |
cover |
Entire span from element entering to fully exiting |
contain |
Only while the element is fully inside the scroller |
entry 20% |
20 % into the entry phase |
cover 0% cover 100% |
Explicit shorthand for the full cover range |
When stacking a global document bar alongside a per-section bar, give each a distinct animation-range so they donβt collapse into the same scroll positions and visually fight each other.
Compositor-Safe Properties
The performance benefit of CSS scroll-driven progress indicators only holds when you animate compositor-safe properties. Everything else forces the browser back onto the main thread for layout or paint.
| Property | Thread | Cost | Use it? |
|---|---|---|---|
transform: scaleX() |
Compositor | None | Yes β preferred for bars |
transform: translateX() |
Compositor | None | Yes β preferred for rings |
opacity |
Compositor | None | Yes |
filter: blur() |
Compositor (usually) | Minimal | Yes, with caution |
clip-path (simple shapes) |
Compositor | Minimal | Yes |
width |
Main thread | Full layout | No β triggers reflow |
height |
Main thread | Full layout | No |
margin / padding |
Main thread | Full layout | No |
background-size |
Main/Paint | Paint | Avoid |
border-radius |
Paint | Paint | Avoid as primary driver |
will-change guidance
.progress-bar {
/* Hint the browser to promote this element to its own GPU layer */
will-change: transform;
}
/* Remove it when the animation is no longer active β each promoted
layer consumes GPU memory. One persistent will-change per viewport
is typically fine; a dozen is not. */
@media (prefers-reduced-motion: reduce) {
.progress-bar {
will-change: auto;
animation: none;
}
}
Common Implementation Patterns
1. Horizontal reading bar (document level)
The canonical pattern: a 4 px stripe fixed to the top of the viewport that fills left-to-right as the reader scrolls the document.
.reading-bar {
position: fixed;
top: 0; left: 0;
width: 100%; height: 4px;
transform-origin: left center;
transform: scaleX(0);
background: var(--color-accent, oklch(60% 0.18 280));
z-index: 100;
animation: fill-bar linear both;
animation-timeline: scroll(root block);
/* animation-range omitted β defaults to full document */
}
@keyframes fill-bar {
to { transform: scaleX(1); }
}
2. Per-section chapter tracker
Each <section> exposes its own named timeline. A sticky bar inside the section tracks only that sectionβs scroll extent.
section.chapter {
scroll-timeline-name: --chapter;
scroll-timeline-axis: block;
/* Must have overflow or be the scroll root */
}
.chapter-progress {
position: sticky;
top: 0;
height: 2px;
transform-origin: left center;
transform: scaleX(0);
background: var(--color-chapter-accent);
animation: fill-bar linear both;
animation-timeline: --chapter;
animation-range: cover 0% cover 100%;
}
3. Radial completion ring
An SVG <circle> element with stroke-dashoffset driven by scroll β useful for βtime to readβ rings beside article headings.
.ring-track {
--circumference: 251.2; /* 2Ο Γ 40 (radius) */
stroke-dasharray: var(--circumference);
stroke-dashoffset: var(--circumference); /* fully hidden at start */
animation: draw-ring linear both;
animation-timeline: scroll(root block);
}
@keyframes draw-ring {
to { stroke-dashoffset: 0; } /* fully visible at end */
}
<svg viewBox="0 0 100 100" width="48" height="48" role="img" aria-label="Reading progress ring">
<circle class="ring-bg" cx="50" cy="50" r="40"
fill="none" stroke="currentColor" stroke-width="8" opacity="0.15"/>
<circle class="ring-track" cx="50" cy="50" r="40"
fill="none" stroke="currentColor" stroke-width="8"
transform="rotate(-90 50 50)"/>
</svg>
4. Multi-section sidebar indicator (table of contents)
A vertical sidebar dot-list where each dot fills as its corresponding section enters the viewport, using view() timelines rather than scroll().
/* Each TOC dot tracks the corresponding section's visibility */
.toc-dot[data-section="intro"] {
animation: fill-dot linear both;
animation-timeline: view(block);
view-timeline-name: --section-intro; /* set on the target section */
animation-range: entry 20% cover 80%;
}
@keyframes fill-dot {
from { opacity: 0.25; transform: scale(0.75); }
to { opacity: 1; transform: scale(1); }
}
Browser Support and @supports Guard
Browser support and progressive enhancement patterns are covered in detail in the Core Fundamentals section. The key summary for scroll progress indicators:
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
animation-timeline: scroll() |
115 (Jul 2023) | 17.4 (Mar 2024) | Flag only | 115 (Jul 2023) |
Named scroll-timeline-name |
115 | 17.4 | Flag only | 115 |
animation-range keywords |
115 | 17.4 | Flag only | 115 |
view() timeline |
115 | 17.4 | Flag only | 115 |
Always gate scroll-timeline declarations with an @supports check:
/* Default: static bar, position driven by JS custom property */
.reading-bar {
transform-origin: left center;
transform: scaleX(var(--scroll-progress, 0));
transition: transform 0.05s linear; /* smooth the JS fallback */
}
/* Enhanced: scroll-timeline takes over when supported */
@supports (animation-timeline: scroll()) {
.reading-bar {
animation: fill-bar linear both;
animation-timeline: scroll(root block);
/* Remove the JS-driven transition β compositor handles it */
transition: none;
}
}
JavaScript fallback for unsupported browsers β uses requestAnimationFrame throttling and { passive: true } so the scroll handler never blocks the main thread:
(function () {
// Exit early if the browser supports scroll-timeline natively
if (CSS.supports('animation-timeline', 'scroll()')) return;
const bar = document.querySelector('.reading-bar');
if (!bar) return;
let ticking = false;
function updateProgress () {
const scrollTop = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = scrollHeight > 0
? Math.min(scrollTop / scrollHeight, 1)
: 0;
// Update the CSS custom property β the transition handles smoothing
bar.style.setProperty('--scroll-progress', progress);
// Update ARIA for assistive technology
bar.setAttribute('aria-valuenow', Math.round(progress * 100));
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(updateProgress);
ticking = true;
}
}, { passive: true }); // passive: true lets the browser scroll without waiting
updateProgress(); // set initial state
}());
Gotchas and Failure Modes
- Using the removed
@scroll-timelineat-rule. The@scroll-timeline { source: selector(#el); }syntax was removed from the spec before Chrome shipped. It will silently fail in all current browsers. Replace withscroll-timeline-nameon the scroll container andanimation-timeline: --nameon the animated element.
-
Animating
widthinstead oftransform: scaleX(). Every frame will trigger a synchronous layout, eliminating the compositor benefit. Chrome DevTools will showLayoutevents on the main thread waterfall during scroll β the definitive signal that you have this bug. -
Named timeline not finding its scroll ancestor.
scroll-timeline-nameonly propagates down through the DOM tree to descendant elements. If your progress bar is positioned outside the scroll container (e.g., a portal or fixed element in a different stacking context), usescroll(root block)instead of a named timeline, or restructure the DOM so the bar is a descendant of the container. -
animation-fill-modemissing. Withoutanimation-fill-mode: both, the bar snaps back to its initial state the moment scroll reaches 0 or 100 %. Setanimation-fill-mode: both(or use the shorthandlinear bothinanimation). -
will-change: transformapplied globally. Applyingwill-changeto every progress bar on a page with many sections forces the browser to create a GPU layer for each one. Limit promoted layers to the elements that are actively animating; avoid addingwill-changevia a global reset or utility class. -
Scroll container height equals viewport height. If the scroll container is exactly as tall as the viewport,
scrollHeight - clientHeight === 0in the JS fallback, causing a division-by-zero and leaving the bar at 0 %. The fallback code above handles this with thescrollHeight > 0guard, but the CSSscroll()path doesnβt produce this bug β another reason to prefer the native approach.
Performance Checklist
DevTools Profiling and Debugging
- Open DevTools β Performance β start a recording β scroll from top to bottom β stop.
- Inspect the Compositor thread track β scroll-driven progress should show no
LayoutorScriptingevents. Any layout activity during scroll means an animated property is triggering reflow. - Open the Layers panel β the progress bar element should appear as a separate GPU layer. A yellow warning triangle means the compositor had to fall back to software rasterisation.
- Open the Animations panel β scrub the timeline manually to verify
animation-rangeboundaries are where you expect. If the bar reaches full width before the page is fully scrolled, youranimation-rangeend value is too low. - Use the Elements panel Computed styles to confirm
animation-timelineis resolved β a value ofautomeans the@supportsguard blocked the rule or the property name is misspelled.
Target metrics: each frame β€ 16.6 ms; zero Layout invalidations during scroll; active promoted layers β€ 10 per viewport.
Frequently Asked Questions
Should the bar track the document or the article?
The article, in almost every case. The document scroll range ends one viewport before the document does β at maximum scroll, the final screenful is already visible β so a bar mapped to the raw scroll range reaches 100% while a screen of text is still unread. Add a comments section or a large footer and the mismatch grows until the indicator is actively misleading.
Scoping the timeline to the article element solves it structurally: the subject supplies its own extent, so the bar completes when the text does, and content below it is correctly excluded.
Why does the bar jump backwards while reading?
Because the page grew. Lazy-loaded images and embeds that arrive without reserved space extend the scrollable range mid-read, so the same scroll position now represents a smaller fraction of a larger whole. The indicator is reporting accurately; the layout is what changed.
Reserving space with aspect-ratio or explicit dimensions fixes this at the source and improves layout stability generally. Scoping the timeline to the article reduces the visible impact but does not eliminate it, because the article itself is what grew.
Is scaleX or width the better implementation?
scaleX, in almost every case: it runs on the compositor, where width forces layout on every frame. The one genuine complication is that scaling distorts anything that should not stretch β a rounded end cap becomes an ellipse, a gradient compresses. The standard resolution is to keep the scaled element rectangular and position an unscaled cap element at its leading edge, rather than switching to width.
Does the indicator need an accessible equivalent?
If it is decorative, hide it from assistive technology entirely β a purely visual progress hint that gets announced as it changes is noise. If it is presented as functional information, give it role="progressbar" with an accessible name, and update aria-valuenow at coarse intervals rather than continuously, because a value that changes every frame produces an unusable stream of announcements.
Most reading indicators are the first kind. Be honest about which one you have built.
Should the indicator appear immediately or fade in after some scrolling?
Fading it in after the reader has committed β a hundred pixels of scroll is a reasonable threshold β reads better than showing a bar sitting at zero on arrival. An empty progress indicator communicates nothing except that the page is long, which is not usually the impression you want at the top of an article.
This is one more animation, so it should use the same timeline rather than a separate mechanism: an opacity animation ranged over the first stretch of scroll, running alongside the scale animation on the same element. Two animations, one timeline, no script.
Related
- Creating a Reading Progress Bar with scroll-timeline β step-by-step long-form guide with React/Vue integration
- The Rendering Pipeline for Scroll Animations β compositor thread, layer promotion, and 16 ms frame budgets explained
- Understanding the CSS Scroll-Timeline API β
scroll()vsview(), named timelines, andanimation-rangesyntax in depth - Browser Support and Progressive Enhancement β
@supportsguard recipes and polyfill strategies for Firefox and legacy Safari