Creating a Reading Progress Bar with scroll-timeline
A reading progress bar that uses legacy scroll event listeners competes with the main thread on every frame, forcing synchronous layout recalculations and risking dropped frames. Using animation-timeline: scroll() from the CSS Scroll-Timeline API moves the entire progress calculation to the compositor thread — no JavaScript involved, no layout thrashing, and a 1:1 scroll mapping within the 16 ms frame budget. This page is part of the Building Scroll Progress Indicators cluster.
When to use this approach
Use animation-timeline: scroll(root block) for a reading progress bar when:
- The indicator must track the entire document, not a sub-scroller —
scroll(root block)maps precisely to the full page scroll range. - You need zero JavaScript for the animation itself; compositor delegation is the goal.
- The bar must remain smooth at 120 Hz on high-refresh displays — a composited
transformis already on the GPU. - You are comfortable gating with
@supportsand supplying arequestAnimationFramefallback for browsers that do not yet ship the feature.
Prefer a ResizeObserver-based JavaScript approach instead when: content height changes dynamically at high frequency (infinite-scroll feeds), or when the progress bar must track a sub-element rather than the root scroller.
Implementation
Step 1: HTML structure
Keep the markup minimal. Two elements — a fixed track and a fill — are all the compositor needs.
<div class="progress-track"
role="progressbar"
aria-label="Reading progress"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100">
<div class="progress-fill" aria-hidden="true"></div>
</div>
role="progressbar" exposes the indicator to assistive technology. aria-valuenow is updated by a lightweight scroll listener (on the main thread, throttled) — ARIA attributes must live in the accessibility tree, which is main-thread only.
Step 2: Core CSS
/* Track: fixed to the viewport top */
.progress-track {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: color-mix(in srgb, currentColor 12%, transparent);
z-index: 100;
/* Isolate from ancestor stacking contexts */
contain: layout style;
}
/* Fill: compositor-animated via scaleX */
.progress-fill {
height: 100%;
background: currentColor;
transform-origin: left center;
transform: scaleX(0);
animation: reading-progress linear both;
animation-timeline: scroll(root block);
}
@keyframes reading-progress {
to { transform: scaleX(1); }
}
Why transform: scaleX() and not width? Animating width triggers layout recalculation on every tick, pulling the browser off the compositor thread’s composited layer pipeline. scaleX is a composited GPU operation with zero layout cost.
Why no animation-range? Omitting animation-range defaults to normal, which for scroll(root block) maps 0% scroll offset to the keyframe start and 100% scroll offset to the keyframe end — exactly the full-document span a reading indicator needs.
Step 3: Progressive enhancement guard
The API shipped in Chrome 115 (July 2023) and Safari 17.4 (March 2024). Gate with @supports so browsers without native scroll-timeline support pick up the JavaScript fallback instead of showing a broken static bar.
/* Default: JS fallback reads --scroll-progress */
.progress-fill {
transform: scaleX(var(--scroll-progress, 0));
}
/* Native path: override with compositor animation */
@supports (animation-timeline: scroll()) {
.progress-fill {
animation: reading-progress linear both;
animation-timeline: scroll(root block);
}
}
The matching JavaScript fallback writes --scroll-progress only when the API is absent:
if (!CSS.supports('animation-timeline', 'scroll()')) {
const fill = document.querySelector('.progress-fill');
const track = document.querySelector('.progress-track');
window.addEventListener('scroll', () => {
const pct = window.scrollY /
(document.documentElement.scrollHeight - window.innerHeight);
fill.style.transform = `scaleX(${Math.min(1, Math.max(0, pct))})`;
track?.setAttribute('aria-valuenow', Math.round(pct * 100));
}, { passive: true });
}
Step 4: Reduced-motion respect
Users who have enabled prefers-reduced-motion in their OS — covered in depth in how to respect prefers-reduced-motion in CSS — should not see continuous animated feedback. Hide the bar or render it static at 100%:
@media (prefers-reduced-motion: reduce) {
.progress-fill {
animation: none;
/* Option A: hide entirely */
/* display: none; */
/* Option B: show as a static full-width line */
transform: scaleX(1);
opacity: 0.3;
}
}
Step 5: ARIA synchronisation
Update aria-valuenow via a passive scroll listener. This runs on the main thread but is deliberately lightweight — only an integer write to the DOM:
const track = document.querySelector('[role="progressbar"]');
window.addEventListener('scroll', () => {
const pct = Math.round(
(window.scrollY /
(document.documentElement.scrollHeight - window.innerHeight)) * 100
);
track?.setAttribute('aria-valuenow', Math.min(100, Math.max(0, pct)));
}, { passive: true });
Verification
After implementing, confirm compositor delegation and correctness with these DevTools steps:
- Chrome DevTools → Elements → Computed. Select
.progress-fill. Confirmanimation-timelineresolves to aScrollTimelineobject — notnoneorauto. - Animations panel. Open DevTools → More tools → Animations. Scrub the document scroll; the
reading-progressanimation should follow 1:1. A bar stalled at a fixed value usually means an ancestor hasoverflow: hiddencollapsing the scroll container. - Performance panel. Record a scroll session. Filter to the Compositor thread — there should be zero
LayoutorScriptingframes during continuous scrolling. - Rendering → Layer Borders. The progress fill should appear on its own GPU-composited layer (highlighted in orange or teal depending on Chrome version).
- Frame budget. Target
<16 msper frame at 60 Hz or<8 msat 120 Hz. AnyLayoutentry in the flame chart during scroll means a non-composited property has leaked into the animation.
Edge cases and gotchas
overflow: hidden on an ancestor collapses scroll bounds. When an ancestor element has overflow: hidden, it becomes the effective scroll container — scrollHeight shrinks to its visible area, and scroll(root block) may read near-zero. Replace with overflow-clip-margin: content-box or clip-path: inset(0) to preserve visual clipping without creating a new scroll container.
Dynamic content injection invalidates the timeline. When infinite scroll or AJAX article loading increases scrollHeight mid-session, the browser does not automatically re-anchor the scroll() timeline bounds. Use a ResizeObserver on document.body to force a style recalculation:
new ResizeObserver(() => {
const fill = document.querySelector('.progress-fill');
if (!fill) return;
// Clear and restore animation to re-derive bounds from new scrollHeight
fill.style.animation = 'none';
fill.offsetHeight; // trigger reflow to flush cached state
fill.style.animation = '';
}).observe(document.body);
iOS Safari overscroll bounce overshoots 100%. Rubber-band bouncing pushes scrollY past the document end, which can drive scaleX above 1 and snap the bar past its track. Add overscroll-behavior: none to <html> to contain bounce within the viewport:
html {
overscroll-behavior: none;
}
Server-side rendering hydration mismatch. Injecting the progress bar in SSR HTML before the client mounts can trigger a React/Vue hydration warning because the server renders aria-valuenow="0" but the client’s scroll position may differ. Defer DOM injection until after the first client paint:
// React: delay until after mount
import { useLayoutEffect, useState } from 'react';
export default function ReadingProgress() {
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<div style={{ contain: 'strict' }}>
<div className="progress-track" role="progressbar"
aria-label="Reading progress" aria-valuenow={0}
aria-valuemin={0} aria-valuemax={100}>
<div className="progress-fill" aria-hidden="true" />
</div>
</div>
);
}
contain: strict on the outer wrapper prevents the stacking context of the progress bar from causing ancestor layout recalculations during hydration.
transform or will-change on an ancestor steals the compositor layer. If a parent element has transform: translateZ(0) or will-change: transform, it creates a new stacking context and can inadvertently become the animation’s containing block, breaking position: fixed. Audit your stacking context tree if the bar disappears or scrolls with the page.
Browser-specific notes
Chrome 115+. Full support for animation-timeline: scroll(root block) with compositor delegation. The Animations panel in DevTools correctly identifies the timeline type. Named scroll timelines (scroll-timeline-name) are also available if you need to scope to a specific container.
Safari 17.4+. Ships the same Level 1 spec. One difference: Safari does not yet support scroll-timeline-name for named timelines on a non-root scroller, so scroll(root block) for document-level progress works fine but custom-scroller variants may need a polyfill. Test animation-fill-mode: both behaviour — Safari’s initial-state handling can differ for the from keyframe when it is omitted.
Firefox. As of mid-2026 Firefox ships scroll-driven animations behind layout.css.scroll-driven-animations.enabled in about:config but has not enabled them by default. The @supports guard is therefore essential — Firefox will fall through to the JavaScript path. Follow the polyfilling scroll-timeline for older browsers guide for a requestAnimationFrame fallback that covers Firefox and pre-17.4 Safari.
View Transition integration. When a SPA page swap replaces route content, the scroll position resets to zero but the compositor animation state may lag until the next paint. Assign view-transition-name: reading-progress to .progress-fill and manually reset the animation inside document.startViewTransition():
.progress-fill {
view-transition-name: reading-progress;
}
async function navigateTo(path) {
if (!document.startViewTransition) {
window.location.href = path;
return;
}
await document.startViewTransition(async () => {
await loadRouteContent(path);
history.scrollRestoration = 'manual';
window.scrollTo(0, 0);
});
// Re-derive the timeline from the new page's scrollHeight
const fill = document.querySelector('.progress-fill');
if (fill) {
fill.style.animation = 'none';
fill.offsetHeight;
fill.style.animation = '';
}
}
Related
- Building Scroll Progress Indicators — parent cluster with section trackers, radial loaders, and named-timeline scoping
- CSS Scroll-Driven Animations vs IntersectionObserver — when the compositor approach beats a callback-based one
- Debugging Scroll Animation Timing Functions — DevTools workflows for scroll-linked animations
- How to Polyfill scroll-timeline for Older Browsers — rAF fallback for Firefox and pre-17.4 Safari
- How to Respect prefers-reduced-motion in CSS — safe patterns for motion-sensitive users