UtilitiesFree

Scroll Progress Bar

A thin gradient bar that fills as people read: it follows the page scroll, or the scroll of a block you point it to.

JSScrollProgress

Updated

Scroll this block: the bar follows your reading.

Each paragraph you read brings the bar closer to the end of the block.

The bar only counts this block, not the whole page: that is the data-rpb-target setting.

Without it, the bar follows the page and sticks to the top of the window.

Screen readers announce the progress, from 0 to 100%.

You are almost there: one more paragraph.

End of the block. The bar is full.

Hover or click the scene to interact

This effect is free — the Utilities category contains 43 effects total, including 3 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Les Heures de Valcorbe Chapter 4 — The flood

IV. The flood

The river had risen during the night. By morning, the lower quays of Valcorbe lay under a grey sheet of water that carried crates, branches and the occasional chair.

Ines watched it from the bridge, hands deep in her coat. Her father had always said the town remembered every flood; the stones kept the marks, one line above the other.

She counted seven lines on the pillar of the old mill. The highest was carved with a date she could no longer read, worn smooth by a century of hands.

Behind her, the bell of the town hall rang nine times. Nobody opened the shops. The baker had set his loaves on the windowsill of the first floor, for whoever could reach them.

A boat came round the corner of the square, rowed by two boys who were clearly enjoying themselves far more than they should. Ines raised a hand; they waved back with an oar.

By noon the water had stopped rising. It would take three days to fall, and three weeks for the smell of mud to leave the cellars. But the town, as always, would add a line to the stone.

End of chapter 4

Ombrevieille editions Chapter 4 of 12

① Online reader — how far into a chapter

WhenThe reader scrolls chapter 4 of Les Heures de Valcorbe in the reading column. The bar just under the header grows and shows at a glance what is left before the end of the chapter.
WhyIn a reader app the header and footer stay put and only the text scrolls, so the bar follows that block, not the page. At 4 px and with no click intercepted, it never gets in the way of reading.
Settingsdata-rpb-target="#lq35-livre", root as the first child of the reading column, aria-labelledby pointing at the chapter title. On load, the scene scrolls the chapter to 32 % so the bar shows right away.

② Terms window — read to the end before accepting

WhenA window shows the terms of a workshop booking service. The "I accept" button stays greyed out until the bar has reached the bottom of the text, then it turns on.
WhyThe bar's value can be read from code: aria-valuenow reaches 100 when the bottom is reached. The scene uses it to enable the button without recomputing the scroll, and the person can see how much is left to read.
Settingsdata-rpb-target="#lq35-cgu", --rpb-height: 3px, grey track --rpb-track: #eef0f4. A MutationObserver in the scene watches aria-valuenow and enables the button from 99. Pre-scrolled to 45 %.

③ Photo picker — a landmark in a narrow film strip

WhenIn the "Choose photos" window, the column of thumbnails scrolls to the left of the large preview. The bar at the top of the column shows where you are in the strip.
WhyA 96 px column has no room for a comfortable scrollbar (hidden here). The progress bar takes its place at the top, and its gradient stands out on the window's dark background.
Settingsdata-rpb-target="#lq35-pellicule", --rpb-height: 4px, track --rpb-track: #2b2f3a, negative side margins so it spans the whole column. Pre-scrolled to 40 %.

How it works

Paste the .fx-0735-root root anywhere in the page. With no setting, it sticks to the top of the window (position: fixed, 4 px tall, z-index: 1000) and its inner .rpb-bar grows from 0 to 100 % as the page scrolls. The length is set with transform: scaleX(), eased over 0.1 s.

To follow a block that scrolls on its own (an article in a window, a message thread, a column of thumbnails), add data-rpb-target with that block's CSS selector and make the root the block's first child. It then switches to position: sticky and stays pinned to the top of the block without pushing the text down (negative bottom margin of the same height). If the selector finds nothing, that bar stays inactive.

The value is recomputed on every scroll (at most once per painted frame), when the window is resized and when the content height changes (ResizeObserver: images loading, text growing). In a designated block, only the elements present on load are watched: after adding new ones (new messages), call root.fx0735.update() or wait for the next scroll. When nothing overflows there is nothing more to read, and the bar stays empty. Each root has its own instance: several bars on one page, or the code pasted twice, never get in each other's way.

Accessibility

  • A real progress bar: the root carries role="progressbar", aria-valuemin="0", aria-valuemax="100" and an integer aria-valuenow kept up to date. Its name comes from aria-label ("Reading progress"); change it, or point aria-labelledby at the article title.
  • Quiet: the value is not a live region. A screen reader reads it when the user reaches it and stays silent while scrolling.
  • Reduced motion: under prefers-reduced-motion: reduce, the 0.1 s easing goes away and the bar follows the scroll with no delay. It never animates on its own: the user is the one scrolling.
  • Never in the way of a click: pointer-events: none on the root lets clicks through to the menu underneath.
  • An extra cue: 4 px of colour is a visual hint only. When reading to the end really matters, also show text ("3 min left", "40 % read").

Browser compatibility

Dependency-free JavaScript, transform and position: sticky (block mode, Chrome 56, Firefox 32, Safari 13). ResizeObserver: Chrome 64, Firefox 69, Safari 13.1. Measured in Chromium 151, Firefox 153 and WebKit 26.5 (Playwright): one bar on the page and one on a target block in the same page, code pasted twice, mouse wheel and touch, 390 px, reduced motion, no name added to window, 0 errors.

Chrome 64+✓ Full (measured)
Firefox 69+✓ Full (measured)
Safari 13.1+✓ Full (measured in WebKit)
Edge 79+✓ Full (Chromium engine)
Mobile iOS 13.4+✓ WebKit engine, not checked on a device
Android Chrome 64+✓ Full (touch measured)

Without JavaScript, the bar stays empty and the page reads as usual. Without ResizeObserver, it updates on scroll and window resize, but not when the content grows by itself.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0735-root" role="progressbar" aria-label="Reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <div class="rpb-bar"></div>
</div>
CSS
.fx-0735-root,
.fx-0735-root *,
.fx-0735-root *::before,
.fx-0735-root *::after {
  box-sizing: border-box
}

/* Default: bar fixed to the top of the window, it follows the page scroll. */
.fx-0735-root {
  --rpb-height: 4px;
  --rpb-track: transparent;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  height: var(--rpb-height);
  background: var(--rpb-track);
  pointer-events: none;
}

/* With data-rpb-target: put the root as the first child of the scrolling container,
   it sticks to the top of that container without pushing its content. */
.fx-0735-root[data-rpb-target] {
  position: sticky;
  margin-bottom: calc(-1 * var(--rpb-height));
}

.rpb-bar {
  height: 100%;
  width: 100%;
  transform: scaleX(0);
  transform-origin: left center;
  background: linear-gradient(90deg, #6366f1, #8b5cf6, #d946ef);
  transition: transform 0.1s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .rpb-bar {
    transition: none
  }
}
JavaScript (fx-0735)
(function () {
  // One instance per root. Without data-rpb-target: the page. With it: the given container (CSS selector).
  function run() {
  document.querySelectorAll('.fx-0735-root').forEach(function (root) {
    if (root.fx0735) return;
    var bar = root.querySelector('.rpb-bar');
    var sel = root.getAttribute('data-rpb-target');
    var box = sel ? document.querySelector(sel) : null;
    if (!bar || (sel && !box)) return;
    var queued = false;

    function update() {
      queued = false;
      var el = box || document.scrollingElement || document.documentElement;
      var max = el.scrollHeight - el.clientHeight;
      var p = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
      bar.style.transform = 'scaleX(' + p + ')';
      root.setAttribute('aria-valuenow', Math.round(p * 100));
    }
    function schedule() {
      if (!queued) { queued = true; requestAnimationFrame(update); }
    }

    (box || window).addEventListener('scroll', schedule, { passive: true });
    window.addEventListener('resize', schedule);
    // The content changes height (images, loaded text): recompute.
    if ('ResizeObserver' in window) {
      var ro = new ResizeObserver(schedule);
      ro.observe(box || document.body);
      if (box) Array.prototype.forEach.call(box.children, function (c) { ro.observe(c); });
    }
    root.fx0735 = { update: update };
    update();
  });
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Followed block (HTML — data-rpb-target) absent: the page CSS selector of the scrolling block (#article, .message-thread). Then make the root that block's first child.
Thickness (CSS — --rpb-height) 4px 2 to 3 px for a discreet line, 6 px for a bar that shows well on mobile.
Track (CSS — --rpb-track) transparent A colour (#eef0f4) draws the full length under the bar, useful when reading to the end matters.
Colours (CSS — background of .rpb-bar) gradient #6366f1 → #8b5cf6 → #d946ef A flat colour or your brand gradient. The gradient stretches with the bar.
Below a fixed header (CSS — top of .fx-0735-root) 0 top: 64px puts the bar just under a 64 px header instead of covering it.
Spoken name (HTML — aria-label) "Reading progress" Replace it with a meaningful title, or use aria-labelledby pointing at a visible heading.
Immediate update (JS — root.fx0735.update()) — After adding or replacing elements in the tracked block, to refresh the bar without waiting for a scroll.

FAQ

Can the bar measure only the article, not the footer?

In page mode it measures the whole document, footer included: it reaches 100 % at the very bottom. To measure only the article, the article has to scroll in its own block (fixed height and overflow-y: auto) and be named in data-rpb-target. The code cannot split a page into sections.

Why does my bar stay empty?

Three possible causes: the page is no taller than the window (nothing to scroll); the data-rpb-target selector finds no element; or your site scrolls a container (a div with overflow: auto filling the screen) instead of the page. In that last case, give that container's selector to data-rpb-target.

Can I have several bars on the same page?

Yes. Each .fx-0735-root root gets its own instance: one bar for the page and one per target block. The script can even be pasted twice: a root that is already set up is skipped.