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:

Anatomy of a progress bar The bar is a fixed-position element scaled on its X axis. transform-origin left pins the growth to the leading edge, scaleX interpolates from 0 to 1, and the animation is driven by scroll(root block) so it maps to the whole document. Nothing here reads a scroll offset in JavaScript. scaleX(0.4) remaining track β€” the untransformed element transform-origin: left The four declarations that make it work position: fixed; inset-block-start: 0; out of flow, so it never shifts content transform-origin: left; grows from the leading edge, not the centre animation: grow linear; scaleX from 0 to 1 β€” a compositor-safe property animation-timeline: scroll(root block); the document's own scroll supplies progress
/* 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.
The bar finishes early The document scroll range ends one viewport before the document does, because the last viewport of content is already on screen at maximum scroll. A bar mapped to the raw scroll range therefore fills while a screenful of article is still unread. Mapping to the article element with a view timeline fixes it. document scrollable range 0% to 100% last viewport still unread when the bar already reads 100% scroll(root block) β€” ends one viewport early view(block) on the article β€” ends with the text The mismatch is invisible on a long page and glaring on a short one β€” which is why it ships. The test article was long enough to hide it.
  1. 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.

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

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

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

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

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.


Up: Scroll-Driven & View Transition Implementation Patterns