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.
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