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.


Reading progress bar compositor flow Diagram showing how scroll offset on the root scroller is consumed by the ScrollTimeline, which drives the scaleX animation on the compositor thread, bypassing the main thread entirely. Main Thread scroll event listener ❌ avoidable Root Scroller scrollTop / scrollHeight ScrollTimeline scroll(root block) Compositor scaleX(0→1) Compositor thread — zero main-thread scripting during scroll

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 transform is already on the GPU.
  • You are comfortable gating with @supports and supplying a requestAnimationFrame fallback 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:

  1. Chrome DevTools → Elements → Computed. Select .progress-fill. Confirm animation-timeline resolves to a ScrollTimeline object — not none or auto.
  2. Animations panel. Open DevTools → More tools → Animations. Scrub the document scroll; the reading-progress animation should follow 1:1. A bar stalled at a fixed value usually means an ancestor has overflow: hidden collapsing the scroll container.
  3. Performance panel. Record a scroll session. Filter to the Compositor thread — there should be zero Layout or Scripting frames during continuous scrolling.
  4. Rendering → Layer Borders. The progress fill should appear on its own GPU-composited layer (highlighted in orange or teal depending on Chrome version).
  5. Frame budget. Target <16 ms per frame at 60 Hz or <8 ms at 120 Hz. Any Layout entry 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 = '';
  }
}