How to add a reading progress bar

Build a thin pink bar that fills across the top of the screen as someone reads your article, with a small JavaScript version and a pure CSS version for browsers that support scroll-driven animations.

A page with a pink progress bar filled partway across the top

Long posts are easier to settle into when you can see how much is left. In this tutorial you'll build a reading progress bar: a thin pink line fixed to the top of the screen that fills from left to right as you read. It tracks the article itself, not the whole page, so it hits 100% at the end of the post instead of at the bottom of the footer. You'll write a small JavaScript version that works everywhere, then a pure CSS version using scroll-driven animations, and let the browser pick the one it can run.

Add the markup

You only need two divs. The outer one is the track that sits across the top of the screen, and the inner one is the bar that fills up.

Put them inside your article, as the first child. That looks a bit odd for something that sits at the top of the screen, but it matters later: the CSS version can only read the article's scroll position if the bar lives inside it. The aria-hidden attribute is on purpose, and we'll get to why near the end.

HTML
<article class="post">
  <div class="reading-progress" aria-hidden="true">
    <div class="reading-progress__bar"></div>
  </div>

  <h1>Your post title</h1>
  <p>Your post content goes here.</p>
</article>

Style the bar

The track is fixed to the top edge, 4px tall, with a faint pink tint so you can see where the bar is headed. pointer-events: none means it never blocks a click on your header.

The bar is always full width. Instead of changing its width, you shrink it with transform: scaleX(0) and grow it back to scaleX(1). Transforms don't make the browser recalculate the page layout, so they stay smooth while you scroll. transform-origin: 0 50% pins the bar to the left edge so it grows to the right instead of from the middle.

CSS
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 4px;
  z-index: 1000;
  pointer-events: none;
  background: rgb(214 42 159 / .15);
}

.reading-progress__bar {
  height: 100%;
  background: #d62a9f;
  transform: scaleX(0);
  transform-origin: 0 50%;
}

Measure progress through the article

The bar should read 0 when the top of the article reaches the top of the screen, and 1 when the bottom of the article reaches the bottom of the screen. getBoundingClientRect gives you the article's position, and the distance you can scroll through it is its height minus the window height. Divide how far the top has moved past the screen edge by that distance and you have your progress. Math.min and Math.max keep it between 0 and 1, so the header and footer don't push it out of range.

Scroll events can fire many times per frame, so the listener doesn't do the math itself. It asks for one requestAnimationFrame, and the ticking flag skips any extra requests until that frame runs. The listener is passive, which tells the browser it will never cancel scrolling. The resize listener catches a window size change, and the ResizeObserver catches the article growing when images load in.

TSX
const article = document.querySelector('.post');
const bar = document.querySelector('.reading-progress__bar');
let ticking = false;

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

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

window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate);
new ResizeObserver(requestUpdate).observe(article);
update();

Let CSS do it with scroll-driven animations

Newer browsers can link an animation to scrolling with no JavaScript at all. view-timeline on the article creates a timeline named --article that follows the article through the screen. The bar uses it with animation-timeline, and animation-range: contain 0% contain 100% maps the animation to the same stretch the JavaScript measures: article top at the screen top, to article bottom at the screen bottom. Put animation-timeline after the animation shorthand, because the shorthand resets it.

The catch is support. According to the browser data MDN publishes, animation-timeline works in Chrome and Edge 115 and up and in Safari 26, while Firefox only has it in preview builds. So everything sits inside @supports, and the JavaScript only runs when the browser says no to the same check. Every reader gets a working bar, and most skip the script.

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

@supports (animation-timeline: view()) {
  .post {
    view-timeline: --article block;
  }

  .reading-progress__bar {
    animation: grow-progress linear both;
    animation-timeline: --article;
    animation-range: contain 0% contain 100%;
  }
}
CSS
if (!CSS.supports('animation-timeline: view()')) {
  // paste the scroll listener code from the previous step here
}

Accessibility and reduced motion

You could mark the bar up as role="progressbar" with aria-valuenow, but then you'd have to update that number on every scroll, and a screen reader would have one more thing to announce that the scrollbar already tells people. This bar is decoration, so aria-hidden="true" keeps it out of the way. If you want to share reading time, a line of text like "6 min read" under the title works for everyone.

The bar moves exactly as far as the reader scrolls, so it doesn't add motion of its own. If you'd like to smooth the JavaScript version with a short transition, wrap it in a prefers-reduced-motion: no-preference media query so it only applies to people who haven't asked for less motion.

CSS
@media (prefers-reduced-motion: no-preference) {
  .reading-progress__bar {
    transition: transform 80ms linear;
  }
}

One last tip. If a parent of the bar has a transform or filter on it, position: fixed stops sticking to the screen and starts sticking to that parent instead. So if your bar scrolls away with the page, check the article's ancestors for a stray transform first.