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


CSS scroll-timeline pipeline for a progress bar Three boxes connected by arrows: Scroll Container feeds scroll offset to the scroll() timeline function, which drives a compositor-thread animation that applies transform scaleX to the Progress Bar element. Scroll Container scrollTop / scrollY mapped 0 β†’ 1 scroll() scroll() Timeline animation-timeline: scroll(root block) runs on compositor thread drives Progress Bar transform: scaleX(0 β†’ 1) no layout / no paint 0 JS Β· 0 scroll listeners Β· compositor-only execution

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

  1. Using the removed @scroll-timeline at-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 with scroll-timeline-name on the scroll container and animation-timeline: --name on the animated element.

  2. Animating width instead of transform: scaleX(). Every frame will trigger a synchronous layout, eliminating the compositor benefit. Chrome DevTools will show Layout events on the main thread waterfall during scroll β€” the definitive signal that you have this bug.

  3. Named timeline not finding its scroll ancestor. scroll-timeline-name only 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), use scroll(root block) instead of a named timeline, or restructure the DOM so the bar is a descendant of the container.

  4. animation-fill-mode missing. Without animation-fill-mode: both, the bar snaps back to its initial state the moment scroll reaches 0 or 100 %. Set animation-fill-mode: both (or use the shorthand linear both in animation).

  5. will-change: transform applied globally. Applying will-change to 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 adding will-change via a global reset or utility class.

  6. Scroll container height equals viewport height. If the scroll container is exactly as tall as the viewport, scrollHeight - clientHeight === 0 in the JS fallback, causing a division-by-zero and leaving the bar at 0 %. The fallback code above handles this with the scrollHeight > 0 guard, but the CSS scroll() path doesn’t produce this bug β€” another reason to prefer the native approach.

Performance Checklist

DevTools Profiling and Debugging

  1. Open DevTools β†’ Performance β†’ start a recording β†’ scroll from top to bottom β†’ stop.
  2. Inspect the Compositor thread track β€” scroll-driven progress should show no Layout or Scripting events. Any layout activity during scroll means an animated property is triggering reflow.
  3. 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.
  4. Open the Animations panel β†’ scrub the timeline manually to verify animation-range boundaries are where you expect. If the bar reaches full width before the page is fully scrolled, your animation-range end value is too low.
  5. Use the Elements panel Computed styles to confirm animation-timeline is resolved β€” a value of auto means the @supports guard 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.


Up: Scroll-Driven & View Transition Implementation Patterns