Scroll Progress Indicator: When It Helps and How to Build It | HorizonX
← The Journal

Scroll Effects

Scroll Progress Indicator: When It Helps and How to Build It

When a scroll progress indicator helps readers, where to place it, and how to build one with CSS scroll-driven animations plus a small JavaScript fallback.

HorizonX

HorizonX

November 3, 2026 · 9 min read

Scroll Progress Indicator: When It Helps and How to Build It

A scroll progress indicator is a thin bar or ring that shows how far a reader has moved through a page. It helps on long, linear content such as articles and documentation; it adds noise on short pages, feeds and app screens. Build it with a CSS scroll-driven animation, add a small JavaScript fallback, and mark it aria-hidden="true".

This guide covers when the pattern is worth adding, where to place it, two CSS versions (whole page and article only), a JavaScript fallback, and the accessibility details that are easy to get wrong.

When a reading progress bar helps, and when it hurts

The indicator answers one question: "How much is left?" That is useful only when the page has a clear beginning and end, and when the reader moves through it in order.

It helps when:

  • The page is a long article, a tutorial or a single-topic docs page that people read from top to bottom.
  • Readers decide whether to keep reading now or later, and an estimate of the remaining length helps that decision.
  • The page has no other strong sense of position, such as numbered steps or a table of contents that highlights the current section.

It hurts or misleads when:

  • The page is short. A bar that fills after two flicks adds motion without information.
  • The content keeps growing (infinite feeds, lazy-loaded lists). The bar jumps backwards whenever more content loads.
  • Comments, related posts and a large footer make up a big part of the page. If the bar tracks the whole document, it reads 60% when the article is actually finished. Track the article instead (see below).
  • The page is an app screen where people jump between regions. There, position is better shown by navigation state.

For docs with many sections, a table of contents that highlights the current heading often helps more than a global bar. The two can coexist, but if you only build one, build the one that answers your readers' actual question.

Placement patterns

  • Top edge bar. A 3 to 4 px bar fixed to the top of the viewport, or directly under a sticky header. This is the most common and least intrusive placement.
  • Ring around a back-to-top button. A circular indicator in a corner that doubles as a control. It needs more care because it is interactive and must have a proper accessible name.
  • Section progress in a sidebar. A thin line next to the table of contents that fills per section. Best for long documentation.

Whatever you choose, keep the indicator out of the way of focus outlines and the header's interactive elements, and give it pointer-events: none if it overlaps anything clickable.

A long article page with a thin progress bar along the top edge, filled to the reader's position, connected to a small settings card and a code window that draw the same bar

Version 1: whole-page progress with CSS only

CSS scroll-driven animations let an animation advance with a scroll position instead of time. The scroll() function creates an anonymous timeline from a scroll container; scroll(root block) uses the document's vertical scroll.

<div class="progress" aria-hidden="true"></div>
.progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: currentColor;
  transform-origin: 0 50%;
  transform: scaleX(0);
  pointer-events: none;
  z-index: 10;
}

@supports (animation-timeline: scroll()) {
  .progress {
    animation: progress linear both;
    animation-timeline: scroll(root block);
  }
}

@keyframes progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Three details matter. The bar scales with transform: scaleX() instead of animating width, so the browser does not recalculate layout on every scroll. animation-timeline comes after the animation shorthand, because the shorthand resets it. And the whole rule sits inside @supports: without it, a browser that ignores animation-timeline would run a zero-length animation and show a full bar from the start.

MDN's scroll progress animations article walks through the same pattern in more depth. On right-to-left pages, set transform-origin: 100% 50% so the bar grows from the right.

Version 2: progress through the article only

To ignore the header, comments and footer, track the article element with a named view timeline. The contain range runs from the moment the article's top reaches the top of the viewport until its bottom reaches the bottom of the viewport, which is exactly "reading progress" for an article taller than the screen:

<article class="post">
  <div class="progress" aria-hidden="true"></div>
  <!-- article content -->
</article>
.post {
  view-timeline: --post block;
}

@supports (animation-timeline: view()) {
  .post .progress {
    animation: progress linear both;
    animation-timeline: --post;
    animation-range: contain 0% contain 100%;
  }
}

The bar lives inside the article because a named timeline is visible to the element's descendants. It still uses position: fixed, which works as long as no ancestor creates a containing block for fixed elements (a transform, filter or contain: paint on the article would). If the bar must sit elsewhere in the DOM, the timeline-scope property on a shared ancestor can expose the timeline more widely.

Browser support (checked October 3, 2026)

According to MDN's browser compatibility data for animation-timeline (data release of October 1, 2026, checked on October 3, 2026), scroll-driven animations are supported in Chrome and Edge 115+ and in Safari 26+ on macOS and iOS. Firefox lists the feature only in its preview builds; the current stable Firefox (version 157, released September 29, 2026) does not support it. Check the live table on caniuse before you rely on it, because this changes. With the @supports guard above, unsupported browsers simply get no bar until the fallback runs.

JavaScript fallback with requestAnimationFrame

For browsers without scroll-driven animations, a small script can set the same transform. Scroll events can fire more often than the screen repaints, so the update is batched with requestAnimationFrame:

const bar = document.querySelector('.post .progress');
const post = document.querySelector('.post');

if (bar && post && !CSS.supports('animation-timeline', 'scroll()')) {
  let queued = false;

  const update = () => {
    queued = false;
    const rect = post.getBoundingClientRect();
    const distance = rect.height - window.innerHeight;
    const progress = distance > 0
      ? Math.min(1, Math.max(0, -rect.top / distance))
      : (rect.top <= 0 ? 1 : 0);
    bar.style.transform = `scaleX(${progress})`;
  };

  const requestUpdate = () => {
    if (!queued) {
      queued = true;
      requestAnimationFrame(update);
    }
  };

  addEventListener('scroll', requestUpdate, { passive: true });
  addEventListener('resize', requestUpdate);
  update();
}

This mirrors the article-only CSS version: 0 when the article's top reaches the top of the viewport, 1 when its bottom reaches the bottom. For whole-page progress, use scrollY / (document.documentElement.scrollHeight - innerHeight) instead. On pages with many articles, an IntersectionObserver can attach the scroll listener only while an article is on screen.

Side-by-side comparison of a progress bar measuring the whole page, which stops short at the comments, and one measuring only the article, which reaches the end with the text

Accessibility: keep it decorative

A reading progress bar repeats information that screen reader users already get from their own navigation, so hide it with aria-hidden="true". Avoid role="progressbar" unless the value is something the user needs. The progressbar role requires an accessible name, and MDN says aria-valuenow should be provided and kept up to date unless the value is indeterminate, which here means updating it on every scroll, and some screen readers report progress bar changes as they happen: NVDA's "Progress bar output" setting can beep or speak on each update (NVDA user guide). That is noise for a value nobody asked for.

If remaining length is genuinely useful, write it as text near the title, such as "8 min read", where everyone can find it.

Visually, a decorative bar does not have to meet the 3:1 non-text contrast requirement, but a bar people cannot see is pointless. Use a color with clear contrast against the header in both light and dark themes, and define it as a custom property so it switches with the theme.

Reduced motion

The bar only moves when the reader scrolls, so it follows their own input rather than starting on its own. Most people who set prefers-reduced-motion will not be bothered by a thin bar. Two things are still worth doing:

  • Do not add smoothing (transition: transform 0.2s) or spring physics to the bar. Delayed, elastic movement is exactly the kind of extra motion that the preference asks you to remove, and it makes the bar lag behind the content.
  • If your indicator is large or decorative (a full-height side line, an animated ring with effects), hide it or show it static under @media (prefers-reduced-motion: reduce).

Checklist

  • The page is long and linear enough that "how much is left" is a useful question.
  • The bar measures the article, not the comments and footer.
  • The CSS version is wrapped in @supports, and a requestAnimationFrame fallback covers other browsers.
  • The bar animates transform, not width.
  • It is aria-hidden="true", does not block clicks, and does not cover focus outlines.
  • It is visible in both themes and has no smoothing or spring effects.

FAQ

Can you make a scroll progress bar with CSS only?

Yes, in browsers that support CSS scroll-driven animations. Animate transform: scaleX() from 0 to 1 and set animation-timeline: scroll(root block) after the animation shorthand, all inside an @supports guard. MDN's compatibility data, checked October 3, 2026, listed support in Chrome and Edge 115+ and Safari 26+ but not in stable Firefox, so pair the CSS with a small JavaScript fallback.

How do you make a scroll progress bar in JavaScript?

Listen for scroll with a passive listener, batch each update in requestAnimationFrame, and set the bar's transform: scaleX() to the progress value. For the whole page, progress is scrollY / (document.documentElement.scrollHeight - innerHeight). For a single article, use getBoundingClientRect(): divide how far the article's top has moved above the viewport by its height minus the viewport height, clamped between 0 and 1. The fallback section above has the full script.

What does animation-range do in a scroll progress bar?

It sets where along the timeline the animation starts and ends (MDN's animation-range reference, checked October 16, 2026). In the article-only version, the article gets a named view timeline, and animation-range: contain 0% contain 100% starts the bar when the article's top reaches the top of the viewport and fills it when the article's bottom reaches the bottom. The header, comments and footer no longer count, so the bar is full when the reading is done.

Related scroll effects

A progress bar is the quietest scroll-linked effect there is. For heavier ones, such as media that expands as you scroll, see Scroll Expand and the rest of the scroll effects collection. Before adding any of them, read our post on scroll-driven and auto-drifting galleries, which covers who should control the motion on a page.