---
name: scroll-progress-indicator
description: Use when you want "Useful, professional, subtle" - Shows reading or page progress as the user scrolls.
---

# Scroll Progress Indicator

> **Category:** Scroll  -  **Personality:** Useful, professional, subtle
>
> **Best use:** Blogs, documentation, long pages
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 1/5 - Visual impact 2/5 - Difficulty 1/5 - Performance cost 1/5

## What it is

A quiet, always-on read-out of how far through a piece of content the reader is. The classic form is a thin bar that fills along the top edge; this demo adds two companions a long-form reader actually benefits from — a **circular % dial** and a **section-dot rail** that highlights the part you are in. Its whole job is reassurance: it tells the reader the journey is finite and how much of it remains, without ever demanding attention. Crucially, the progress here is **scoped to the article's own scroll container**, not the browser window, so it measures *this* document and nothing else.

## Dependencies / CDN

**None.** The top bar is pure CSS (`animation-timeline: scroll()`); the dial, dots and "now reading" label are a few lines of vanilla JS. The demo's display/body fonts are optional:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&display=swap" rel="stylesheet">
```

## HTML

A scope element (carries the timeline name), the fill bar, a progress ring, a section rail, and the scroll container that holds the article:

```html
<div class="sp-stage" id="sp-stage">           <!-- timeline-scope owner -->
  <div class="sp-bar"><span class="sp-bar-fill"></span></div>

  <header class="sp-head">
    <span class="sp-now-sec" id="sp-now-section">Introduction</span>
    <div class="sp-ring" role="progressbar" aria-label="Reading progress"
         aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
      <svg viewBox="0 0 44 44">
        <circle class="sp-ring-bg" cx="22" cy="22" r="18"></circle>
        <circle class="sp-ring-fg" cx="22" cy="22" r="18"></circle>
      </svg>
      <span class="sp-ring-num"><span class="sp-pct">0</span><i>%</i></span>
    </div>
  </header>

  <div class="sp-main">
    <nav class="sp-rail">
      <span class="sp-rail-track"><span class="sp-rail-fill"></span></span>
      <a class="sp-toc" href="#sp-s1"><span class="sp-dot"></span><span>The weight of a page</span></a>
      <!-- one .sp-toc per section… -->
    </nav>

    <div class="sp-scroll" id="sp-scroll" tabindex="0"
         role="region" aria-label="Article body — scroll to read">
      <article>
        <section class="sp-sec" id="sp-s1" data-label="The weight of a page">…</section>
        <!-- more sections… -->
      </article>
    </div>
  </div>
</div>
```

## CSS

```css
/* 1) Scope the timeline name to a common ancestor so the bar (which is NOT
      inside the scroller) can still reference it. */
.sp-stage{ timeline-scope: --sp-read; position:relative; overflow:hidden; border-radius:24px; }

/* 2) The scroll container OWNS the timeline. Progress = this element's scroll. */
.sp-scroll{
  height: clamp(420px, 58vh, 560px);
  overflow-y: auto;
  scroll-timeline-name: --sp-read;
  scroll-timeline-axis: block;          /* shorthand: scroll-timeline:--sp-read block */
}

/* 3) The top bar fills natively — zero JS — from 0%→100% of that scroll range. */
.sp-bar-fill{ transform: scaleX(0); transform-origin: left center; }
@keyframes sp-fill{ from{transform:scaleX(0)} to{transform:scaleX(1)} }
@supports (animation-timeline: scroll()){
  .sp-bar-fill{ animation: sp-fill linear forwards; animation-timeline: --sp-read; }
}

/* 4) Circular dial: stroke-dasharray = circumference (2·π·r ≈ 113.1 for r=18);
      JS drives stroke-dashoffset from full→0. */
.sp-ring-fg{
  fill:none; stroke:#b9502b; stroke-width:4; stroke-linecap:round;
  stroke-dasharray:113.1; stroke-dashoffset:113.1;   /* empty until JS runs */
  transition: stroke-dashoffset .14s linear;
}
.sp-ring svg{ transform: rotate(-90deg); }            /* start the arc at 12 o'clock */

/* 5) Section dots: read (is-done) + current (is-active) states. */
.sp-dot{ width:12px; height:12px; border-radius:50%; background:#fffdf8; border:2px solid #e2d5bb; }
.sp-toc.is-done  .sp-dot{ background:#b9502b; border-color:#b9502b; }
.sp-toc.is-active .sp-dot{ background:#b9502b; border-color:#b9502b; transform:scale(1.28);
  box-shadow:0 0 0 4px rgba(185,80,43,.16); }
.sp-rail-fill{ height:0; }                            /* JS sets height to the % */
```

## JavaScript

The bar is native CSS; JS only computes the number for the dial, the rail fill, the active section, and acts as a **fallback** for the bar where `scroll-timeline` is unsupported (Firefox/Safari today).

```js
(function(){
  var scroller = document.getElementById('sp-scroll');
  var ringFg = document.querySelector('.sp-ring-fg');
  var pctEl  = document.querySelector('.sp-pct');
  var barFill= document.querySelector('.sp-bar-fill');
  var railFill = document.querySelector('.sp-rail-fill');
  var nowSec = document.getElementById('sp-now-section');
  var tocs = [].slice.call(document.querySelectorAll('.sp-toc'));
  var secs = [].slice.call(document.querySelectorAll('.sp-sec'));

  var C = 2 * Math.PI * 18;                              // ring circumference
  var reduce   = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var nativeBar= !reduce && CSS.supports('animation-timeline','scroll()');

  var tops = [], ticking = false, lastActive = -2;
  function measure(){ tops = secs.map(function(s){ return s.offsetTop; }); }

  function render(){
    ticking = false;
    var max = scroller.scrollHeight - scroller.clientHeight;
    var st  = scroller.scrollTop;
    var p   = max > 0 ? Math.min(1, Math.max(0, st / max)) : 0;

    ringFg.style.strokeDashoffset = (C * (1 - p)).toFixed(2);   // dial
    pctEl.textContent = Math.round(p * 100);                    // number
    railFill.style.height = (p * 100) + '%';                    // rail line
    if (!nativeBar) barFill.style.transform = 'scaleX(' + p + ')'; // bar fallback

    // active section = last one whose top has crossed the reading line
    var marker = st + Math.min(120, scroller.clientHeight * 0.3), active = -1;
    for (var i = 0; i < tops.length; i++) if (tops[i] <= marker) active = i;
    if (active !== lastActive){
      lastActive = active;
      tocs.forEach(function(t, j){
        t.classList.toggle('is-done', j < active);
        t.classList.toggle('is-active', j === active);
      });
      nowSec.textContent = active >= 0 ? secs[active].dataset.label : 'Introduction';
    }
  }

  scroller.addEventListener('scroll', function(){
    if (!ticking){ ticking = true; requestAnimationFrame(render); }
  }, { passive: true });
  addEventListener('resize', function(){ measure(); render(); });

  // table-of-contents jumps the CONTAINER, never the page
  tocs.forEach(function(a){
    a.addEventListener('click', function(e){
      e.preventDefault();
      var el = document.getElementById(a.getAttribute('href').slice(1));
      if (el) scroller.scrollTo({ top: Math.max(0, el.offsetTop - 18),
                                  behavior: reduce ? 'auto' : 'smooth' });
    });
  });

  measure(); render();
})();
```

## How it works

- **`scroll-timeline-name` on the scroller** turns that element's own scroll position into a timeline that runs 0%→100% from top to bottom. The bar's `@keyframes` (`scaleX(0)`→`scaleX(1)`) are mapped onto it by `animation-timeline: --sp-read`, so the fill tracks scroll with **no JavaScript and no main-thread work**.
- **`timeline-scope: --sp-read` on the wrapper** hoists that name up to a common ancestor. Without it the name is only visible to the scroller's descendants — but the bar lives in the masthead, a *sibling* subtree, so it would never resolve.
- **The dial** is a circle whose `stroke-dasharray` equals its circumference; pushing `stroke-dashoffset` from the full length down to `0` draws the arc. `rotate(-90deg)` moves the start point to the top.
- **The number, rail fill and active dot** come from one `requestAnimationFrame`-throttled handler reading `scrollTop / (scrollHeight − clientHeight)`. The active section is simply the last `<section>` whose `offsetTop` has passed a line ~30% down the viewport.

## Customization

- **Recolor** by changing one accent (`--sp-acc`/the stroke + dot + bar gradient). The whole effect reads from it.
- **Bar only?** Keep steps 1–3 and delete the ring/rail — that is the minimal, dependency-free progress bar.
- **Whole-page instead of a container:** drop `timeline-scope`/`scroll-timeline-name`, use the anonymous root timeline `animation-timeline: scroll(root block)` for the bar, and read `document.scrollingElement` in JS.
- **Dial size:** change the circle's `r`, then set `stroke-dasharray`/initial `stroke-dashoffset` to `2·π·r` (and `C` in JS) — they must match or the ring won't reach 100%.
- **Active threshold:** tune the `Math.min(120, clientHeight*0.3)` reading line to taste.

## Accessibility & performance

- The dial is a `role="progressbar"` with `aria-valuemin/max/now`; JS also sets `aria-valuetext` ("42% read") so screen readers announce something meaningful.
- The scroll region is `tabindex="0"` + `role="region"` with a label, so keyboard users can focus it and scroll with arrow keys.
- A progress read-out is *information*, not decoration, so it keeps working under `prefers-reduced-motion` — but smoothing is removed: in-page anchor jumps fall back to `behavior:'auto'` and the catalog's reduced-motion rule strips transitions. The decorative "scroll" hint stops bobbing.
- Cheap by design: the native bar runs off the main thread; the JS handler is `passive` + rAF-throttled and touches only transforms/`stroke-dashoffset`/text — no layout thrash beyond reading scroll metrics.

## Gotchas

- **Name resolution is the #1 trap.** A named scroll timeline is only visible to the scroller's descendants and following siblings. A top-of-page bar usually lives elsewhere in the DOM — use `timeline-scope` on a shared ancestor (as here) or you'll see *nothing*.
- **Never give the keyframe animation a duration without guarding it.** `animation: sp-fill linear forwards` with no scroll timeline runs as a 0s time animation and **jumps straight to 100%**. Gate the `animation`/`animation-timeline` inside `@supports (animation-timeline: scroll())` and drive the bar from JS otherwise.
- The `animation` *shorthand* resets `animation-timeline` to `auto`; always declare `animation-timeline` **after** it.
- For the JS active-section math, the sections' `offsetParent` must be the scroll container — give the scroller `position: relative` so `offsetTop` is measured against it.
- **Scope, scope, scope.** If you bind progress to the window while the content scrolls inside a fixed-height box (or vice-versa), the indicator drifts out of sync. Anchor links also scroll the *nearest* scroller — `preventDefault()` and call `scroller.scrollTo()` so the page itself never jumps.
- Circumference must equal `2·π·r`; a mismatched `stroke-dasharray` leaves the ring stuck short of (or past) full.
