12k
All articles

How to Create a Reading Progress Bar

Build a reading progress bar with JavaScript or CSS scroll-driven animations, including correct scroll math, performance, and accessibility.

OpenReplay Team
OpenReplay Team
How to Create a Reading Progress Bar

A reading progress bar is a thin, fixed indicator (usually pinned to the top of the viewport) that fills from 0% to 100% as the reader scrolls through a long article.

The first version I shipped hit 100% about three screens before the end of the post, because it was quietly measuring the comments and the footer along with the article. Getting that one detail right turns out to be most of the work.

You can build one two ways: a JavaScript scroll listener that sets a bar’s width from a scroll-percentage calculation, or a pure-CSS scroll-driven animation with no JavaScript at all. This guide gives you both, the correct scroll math for document-scoped and article-scoped bars, the performance details that keep the scroll handler cheap, and the accessibility and progressive-enhancement handling you need before shipping.

Key Takeaways

  • For a whole-document bar, scroll progress is scrollTop / (scrollHeight − clientHeight) × 100; for a bar that tracks the article only, measure the <article>: window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100.
  • Use the article-scoped formula when the page has related-posts blocks, comments, or a tall footer, so the bar reaches 100% at the end of the post rather than the bottom of the page.
  • Because scroll fires on nearly every frame, run the width update inside requestAnimationFrame and cache height reads, recomputing only on resize, so the handler never forces synchronous layout.
  • The CSS-only version needs no JavaScript: give a fixed bar animation-timeline: scroll(), a @keyframes that animates transform from scaleX(0) to scaleX(1), and animation-duration: 1ms, which is what Firefox needs before it will apply the animation at all, behind its flag or in Nightly.
  • Scroll-driven animations ship in Chrome/Edge 115+, Safari 26+, and Opera, but they are not Baseline yet, because stable Firefox still hides them behind a flag. Treat the CSS-only bar as progressive enhancement.

What is a reading progress bar, and when should you use it?

A reading progress bar visually encodes “how much of this post is left” as a bar that grows across the top of the screen. It suits long-form content (deep tutorials, essays, documentation) where a reader benefits from a sense of position that a modern thin scrollbar no longer provides. On short pages, a landing page, or anything that fits in a viewport or two, it adds visual noise without informing anyone; skip it there.

Two design decisions drive the rest of the build: which region the bar measures (the whole document or just the article body), and whether you implement it in JavaScript or in CSS.

How do you calculate reading progress?

Get the math right and everything else follows. There are two correct formulas depending on what you want the bar to represent.

Whole-document scroll. For a bar that fills as the entire page scrolls, progress is the scrolled distance divided by the maximum scrollable distance:

progress = scrollTop / (scrollHeight − clientHeight) × 100

The denominator subtracts the visible height because you can never scroll the last viewport-worth of content out of view: the bottom of the page is reached while a full screen is still visible. On the root scroller, scrollHeight is the total content height and clientHeight is the visible height.

Article-scoped scroll. A whole-document bar counts your footer, comments, and related-posts blocks, so it hits 100% at the bottom of the page, not the bottom of the post. To fix that, measure the <article> element instead:

distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100

Here distance is the scroll trajectory from the first paint to the moment the article’s bottom edge enters view. Use the article-scoped formula when your page has anything substantial below the post; use the document formula when the scrollable content is the whole page. Note that offsetTop is measured relative to the nearest positioned ancestor, so keep the article in the normal document flow for the number to mean “distance from the top of the page.”

JavaScript implementation

The JavaScript approach works in every browser and is the only way to get accurate article-scoped progress. You need a fixed bar element, a little CSS, and a scroll handler.

<div id="progress-bar" aria-hidden="true"></div>
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 4px;
  background: linear-gradient(to right, #7b2ff7, #f107a3);
  z-index: 9999;
}
const bar = document.getElementById("progress-bar");
const article = document.querySelector("article");
let distance = 0;
let ticking = false;

function measure() {
  distance = (article.clientHeight + article.offsetTop) - window.innerHeight;
}

function update() {
  const progress = Math.min((window.scrollY / distance) * 100, 100);
  bar.style.width = `${progress}%`;
  ticking = false;
}

function onScroll() {
  if (!ticking) {
    requestAnimationFrame(update);
    ticking = true;
  }
}

window.addEventListener("load", () => { measure(); update(); });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);

Measurements run in the load handler so images and fonts have settled and clientHeight is accurate. Swap the article-scoped distance for the document formula if you want a whole-page bar.

Keeping the scroll handler fast

The scroll event can fire on nearly every animation frame, so a naive handler that reads layout and writes styles on each event is a reliable source of jank. Two rules keep it cheap.

First, batch the visual write into requestAnimationFrame using the ticking flag above, so you update the bar at most once per frame regardless of how often scroll fires. Second, cache your height reads. Reading clientHeight/offsetTop on every scroll event forces the browser to flush pending layout, and those repeated reflows are what layout thrashing looks like in practice, so compute distance once and recompute it only on resize. A common production failure mode is exactly this: an unthrottled listener that reads geometry and writes width every event, and session replays of scroll-heavy pages frequently surface the resulting frame drops. Registering the listener as { passive: true } also tells the browser you won’t call preventDefault, so scrolling stays smooth.

The CSS-only reading progress bar

You can build the bar with zero JavaScript using CSS scroll-driven animations. Bind an animation to a scroll timeline instead of to elapsed time, and the browser drives the bar’s horizontal scale from scroll position. Because the animation targets a transform rather than a layout property, it can run on the compositor instead of going through a scroll listener on the main thread.

<div id="reading-progress" aria-hidden="true"></div>
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    #reading-progress {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 4px;
      z-index: 9999;
      background: #7b2ff7;
      transform: scaleX(0);
      transform-origin: left;
      animation-name: grow-progress;
      animation-timeline: scroll();
      animation-duration: 1ms; /* required so the animation runs in Firefox */
      animation-timing-function: linear;
    }
    @keyframes grow-progress {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }
    @media (prefers-color-scheme: dark) {
      #reading-progress { background: #fc0; }
    }
  }
}

The bar is laid out at full width and squashed to nothing with transform: scaleX(0), then scaled back up as you scroll. transform-origin: left is what makes it grow from the left edge rather than from the centre. Animating width instead would look identical but force layout on every frame, which pulls the animation back onto the main thread.

Two more details matter. Called with no arguments, scroll() picks the closest ancestor that scrolls and follows its block axis, which for most single-column article layouts means the root scroller; pass root if you want to name it explicitly. And Firefox refuses to apply the animation unless animation-duration is non-zero, so the customary 1ms is what makes it run there, and the same value keeps the bar hidden in browsers that lack support.

That last point is the tradeoff. animation-timeline is not Baseline. It ships in Chrome and Edge 115+, Safari 26+, and Opera, while stable Firefox still keeps it behind the layout.css.scroll-driven-animations.enabled flag and turns it on by default only in Nightly. The @supports guard above is the progressive-enhancement contract: supporting browsers get the CSS bar, others get nothing rendered, so pair it with the JavaScript version as a fallback if you need universal coverage. Also note the CSS-only bar measures the full scroll container, so it counts footer and comment content just like the document-scoped JS formula.

JavaScript vs CSS-only: which to use

JavaScript barCSS-only bar
Browser supportEverywhereChromium 115+, Safari 26+; Firefox behind a flag
Article-scoped accuracyYesNo, it counts the whole page
Main-thread costScroll listenerNone per frame, the transform runs on the compositor
JavaScript requiredYesNo

Use JavaScript when you need the bar to stop at the end of the post or must support every browser; use the CSS-only bar when you want a whole-page indicator with minimal code and can treat it as an enhancement.

Accessibility and polish

A progress bar is decorative chrome, so mark it aria-hidden="true" to keep it out of the accessibility tree and away from screen-reader output and focus order. If you genuinely want the value announced, use role="progressbar" with a live aria-valuenow instead, though for most reading indicators, hiding it is correct. Wrap the CSS animation in @media (prefers-reduced-motion: no-preference) so users who opt out of motion don’t get an animating element, and pick a bar color with sufficient contrast against your header so it stays visible in both light and dark themes.

Both approaches produce the same visible result; the JavaScript version buys you article-scoped accuracy and universal support, while the CSS-only version buys you a smaller implementation that keeps its per-frame work off the main thread. Start with whichever matches your browser targets, keep the scroll math and the animation-duration: 1ms detail exactly as shown, and layer the two with @supports if you want the best of both.

FAQs

Why does my progress bar reach 100 percent before I finish reading the article?

The bar is measuring the whole document instead of the article, so it counts your footer, comments, and related-posts blocks in the scrollable distance. Switch to the article-scoped formula: compute distance as (article.clientHeight + article.offsetTop) minus window.innerHeight, then divide window.scrollY by that distance. The bar then reaches 100 percent at the bottom of the post rather than the bottom of the page.

Why does the CSS-only progress bar work in Chrome but not in Firefox?

Firefox keeps scroll-driven animations behind the layout.css.scroll-driven-animations.enabled flag in its stable releases, with the pref on by default only in Nightly, so an unflagged Firefox renders nothing. Separately, Firefox will not apply the animation at all unless animation-duration is non-zero, which is why 1ms is the value everyone uses. Pair the CSS bar with an at-supports guard and a JavaScript fallback for full coverage.

Does the CSS-only bar run without a scroll event listener?

Yes. CSS scroll-driven animations bind the animation to a scroll timeline rather than elapsed time, so the browser drives the bar's transform directly from scroll position with no JavaScript scroll listener and no IntersectionObserver on the main thread. Animating a transform rather than width is what keeps it compositor-friendly: in Chromium and Safari 26.4 or later the animation runs on the compositor thread, while earlier Safari 26.x versions ran scroll-driven animations on the main thread instead. Animating width or height would force layout on every frame and put the work back on the main thread in every browser.

Should a reading progress bar be exposed to screen readers?

No, for most reading indicators. A progress bar is decorative chrome, so mark it aria-hidden='true' to keep it out of the accessibility tree, away from screen-reader output, and out of the focus order. Only if you genuinely need the value announced should you use role='progressbar' with a live aria-valuenow attribute instead, but hiding a purely visual reading indicator is the correct default.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.