ScrollFree

Scroll Progress Beam

A 4 px bar in the left margin, filled with a mint → blue gradient up to scrollTop / (scrollHeight − clientHeight), a 12 px white dot at its head and a percentage in the top-right corner: a single CSS variable, --spb-p, written by a passive scroll listener, inside a 16/10 box capped at 340 px so that there is always something left to scroll.

ScrollCSS VariablesVanilla JS

Chapitre 1 — Le principe

Scrollez à l'intérieur de cette zone : la barre lumineuse à gauche se remplit au rythme exact de votre lecture, avec un halo qui réagit à la position. Plus vous avancez, plus le faisceau monte.

Chapitre 2 — Le repère

Le curseur lumineux glisse le long de l'axe vertical. C'est un repère de progression élégant et discret, parfait pour un article long, une page de documentation ou un récit qui se déroule au scroll.

Chapitre 3 — La mécanique

Aucune dépendance externe : un simple écouteur de scroll calcule le ratio défilé / hauteur totale et met à jour une variable CSS. Léger, fluide, et entièrement accessible.

Chapitre 4 — La performance

L'écouteur est passif et ne touche qu'à une custom property, donc zéro reflow coûteux. Le rendu reste à 60 fps même sur mobile, et la barre suit le doigt au pixel près.

Chapitre 5 — L'accessibilité

La barre est purement décorative (aria-hidden) : le contenu reste pleinement lisible et navigable au clavier. En mode « mouvement réduit », le défilement instantané remplace le smooth-scroll.

Chapitre 6 — La fin

Arrivé tout en bas, la barre est pleine à 100% et le pourcentage l'indique. Un repère clair, du début à la fin, sans jamais voler l'attention au contenu.

Hover or click the scene to interact

This effect is free — the Scroll category contains 70 effects total, including 12 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

LONGFORM Investigation
32 min read · 8,000 words
Coastline · Three-part series
The last inhabited lighthouses
By Claire Aubry · Photographs Jules Marin

At the Pointe du Raz, keeper Yann Le Goff still climbs the 214 steps every evening. He is one of the last eleven in France to live in the tower he maintains, and he knows that no one will take over.

A light nobody watches any more

Since 1990, automation has emptied the lighthouses one by one. The lenses turn on their own, driven from Brest, and the foghorns fire on a sensor. The men have left, but the buildings still need hands.

Salt eats the frames, the wind tears off the zinc, and a tower left one winter without a visit loses ten years in a season. That is what the lighthouse engineers call, without irony, the maintenance debt.

What is left to pass on

Yann agreed to let his daughter film his routines: greasing the bearings, reading the barometer, the way to wedge the door when the storm comes from the south-west. A hundred hours of footage that no school asks for.

Local authorities hesitate. A listed lighthouse costs 300,000 euros a decade; one open to the public sometimes brings in twice that, but requires a reception, a car park, toilets, and a road that does not always exist.

In Scotland, the Northern Lighthouse Board has decided: the towers are leased to families who commit to keeping them. The model interests Brittany, where four buildings could apply as early as next year.

On the evening of our visit, the light came on at 6:42 pm, exactly as the day before. Yann looked at his watch, then at the sea, and said that someone would have to keep watching.

① 8,000-word long read — magazine reading app

WhenReading view of an editorial app: header with the magazine name and reading time, then the text column over the full height, the rail in the left margin.
WhyOver 8,000 words the reader wants to know where they are without leaving the line they are reading; the rail sits 14 px from the edge, outside the column, and covers nothing. Introduction (fx-0575) puts its horizontal bar at the top of the column, inside the content area, where it takes a line in full screen. Here the column is whole, and the 12 px pill fits in the corner.
Settingsaspect-ratio and max-height replaced by the view height (rail = height − 36 px), padding-left: 40px kept for the rail, serif body at 13.5 px / 1.65, amber gradient #f6d28a → #e0862d with matching halos, dot kept, pill at 11 px.
ORION IV
Solo transatlantic · Les Sables-d'Olonne → Pointe-à-Pitre
DAY 6 / 14

Day 1 — 17 November

46°29'N 1°47'W · SW 14 kn · 0 NM

Cast off at 9:10 under a low sky. The channel was full of people, then no one. For the first hour, you only think about what you forgot.

Day 2 — 18 November

45°02'N 4°55'W · W 22 kn · 168 NM

Bay of Biscay, cross swell. Two reefs in the mainsail before nightfall. The autopilot holds well, me less so; I slept in twenty-minute slices.

Day 3 — 19 November

42°10'N 9°40'W · NW 18 kn · 240 NM

Cape Finisterre passed offshore, forty miles out. The wind veered as forecast and the boat finally settled on the right tack. First hot coffee without spilling it.

Day 4 — 20 November

38°55'N 13°20'W · N 25 kn · 262 NM

Steady northerly, storm spinnaker all day. A cargo ship crossed three miles off without answering the VHF. The barometer is climbing, the sea is slowly settling.

Day 5 — 21 November

35°30'N 17°05'W · NE 20 kn · 251 NM

The trade wind sets in. Repaired the spinnaker halyard chafed at the masthead, an hour up the mast. From up there, the wake is the only straight line on the ocean.

Day 6 — 22 November

32°04'N 21°30'W · NE 17 kn · 236 NM

First truly black night of stars. Three flying fish on deck at dawn, one in the cockpit. 2,300 miles to go and I no longer want to arrive.

② Log of a solo Atlantic crossing — the rail as a depth sounder

WhenStory page of a solo crossing, one chapter per day: date, position, wind, then the day's text; the header shows the boat and the current day.
WhyThe white dot and its halo evoke an onboard instrument, and a vertical rail sinking as the days go by reads like a sounder: reading becomes a descent into the log. Snap + Progress (fx-0597) gives a horizontal bar and one dot per section — functional, without imagery; the rail carries the story without a single extra pixel.
SettingsCyan → deep blue gradient #8ff0ff → #2f6bff, dot halo widened to 0 0 18px 5px, rail thickened to 5 px, positions and wind in monospace, pill kept as is (the log percentage), current day shown by the scene header.
deploy-prod #4821 · main
build ✓ tests ✓ deploy ●
Running · 2:33
112:04:01▶ Stage 1/3 — build
212:04:01$ git fetch --depth=1 origin main
312:04:02$ npm ci
412:04:19added 1,284 packages in 17s
512:04:19$ npm run build
612:04:41vite v6.0.3 · 214 modules transformed
712:04:41dist/assets/index-8c1f.js 182.4 kB │ gzip: 58.9 kB
812:04:41dist/assets/index-2a7e.css 41.2 kB │ gzip: 9.7 kB
912:04:42✓ build finished in 22.6s
1012:04:42▶ Stage 2/3 — tests
1112:04:42$ npx vitest run --coverage
1212:04:55✓ src/auth/session.test.ts (18 tests) 412 ms
1312:04:56✓ src/api/orders.test.ts (42 tests) 1,208 ms
1412:04:57✓ src/api/payments.test.ts (27 tests) 933 ms
1512:04:58⚠ src/ui/cart.test.ts › 1 test skipped
1612:04:59Test files 14 passed (14)
1712:04:59Tests 231 passed · 1 skipped (232)
1812:04:59Coverage 91.4% lines · 84.2% branches
1912:05:00▶ Stage 3/3 — deploy
2012:05:00$ docker build -t registry.internal/api:4821 .
2112:05:01#1 [internal] load build definition from Dockerfile
2212:05:01#4 [1/6] FROM node:22-alpine
2312:05:04#7 [4/6] COPY package*.json ./
2412:05:22#8 [5/6] RUN npm ci --omit=dev
2512:05:23#9 [6/6] COPY dist ./dist
2612:05:25#10 exporting layers · sha256:9be1…c2d4
2712:05:26$ docker push registry.internal/api:4821
2812:05:484821: digest sha256:71d0…e9aa size 2417
2912:05:49$ kubectl set image deploy/api api=registry.internal/api:4821 -n prod
3012:05:49deployment.apps/api image updated
3112:05:50$ kubectl rollout status deploy/api -n prod
3212:05:51Waiting: 1 of 4 updated replicas available…
3312:06:04Waiting: 2 of 4 updated replicas available…
3412:06:19Waiting: 3 of 4 updated replicas available…
3512:06:33Readiness probe pending on api-7c9d4-x2k1q (1/2)
3612:06:34

③ CI/CD pipeline log — completion without covering a line

WhenMonitoring dashboard: pipeline name, stages, then the build log in monospace growing longer; the rail says where you are in the log.
WhyEvery pixel of the central area is a log line; a bar at the top of the page (Scroll Progress Bar, fx-0547) blends with the browser's loading indicator and the engineer no longer knows which one they are looking at. A vertical rail in the gutter, next to the line numbers, looks like nothing else — and it stays strictly 0% / 100% of the reading position, not of the job's progress.
SettingsGreen gradient #9ef7b8 → #22c55e (success), 3 px rail, 10 px dot, monospace pill on the terminal background, 11.5 px lines, scroll-behavior: auto so that following new lines is instant.

How it works

Everything lives in .spb-scene: position: relative, width: 100%, aspect-ratio: 16 / 10 capped by max-height: 340px (its height comes from these two rules alone, since every child is absolutely positioned), overflow: hidden, isolation: isolate, rgb(12, 16, 34) background, 14 px corners, and the font stack Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif declared once and inherited by the headings, the paragraphs and the pill. Inside, three absolutely positioned children. .spb-scroll (inset: 0, overflow-y: auto, padding: 22px 22px 22px 40px, scroll-behavior: smooth) holds the text — six 16 px h4 and six 13 px / 1.7 p — and hides its native scrollbar with scrollbar-width: none backed by ::-webkit-scrollbar { width: 0 } (measured: offsetWidth = clientWidth, no gutter). .spb-beam, the rail: left: 14px, top: 18px, bottom: 18px, 4 px wide, white at 10%, z-index: 3, pointer-events: none — so it measures the box height minus 36 px (304 px under the cap). Its fill .spb-beam-fill has a height of calc(var(--spb-p, 0) * 100%), an rgb(92, 255, 216) → rgb(109, 139, 255) gradient and two shadows (12 px blue at .8, 4 px mint at .9); the dot .spb-beam-dot, 12 px white, 0 0 14px 3px halo, sits at top: calc(var(--spb-p, 0) * 100%) and is re-centred by translate(-50%, -50%). .spb-pct, the pill (right: 14px, top: 12px, 12 px, tabular-nums, black at 40%), shows the percentage.

The JavaScript is an ES5 IIFE that handles every .spb-scene on the page (measured with two instances: 0% and 25%, independent). Its update() computes max = scrollHeight − clientHeight, then p = scrollTop / max (0 when max ≤ 0), writes --spb-p on the rail with toFixed(4) and the text Math.round(p × 100) + '%' into the pill — if there is one. It runs once at initialisation, on every scroll of the box ({ passive: true }) and on every window resize. No transition, no easing: the rail follows the scroll frame by frame. Measured in Chromium on a blank 1,280 px page, 1,264 × 340 px box, 125 px of travel: one 60 px wheel notch gives 0.4800 → "48%", 145.9 px of fill on a 304 px rail and the dot's centre at exactly 145.9 px; 0.9600 on the second notch, 1.0000 on the third, "100%", the dot overhanging the rail by 6 px (the translate); wheel back, 0.0000 again. 200 calls to update() cost 0.2 ms. scroll-behavior: smooth only affects programmatic scrolls: scrollTo(max) fires 21 scroll events and reaches 1 in ≈ 170 ms — a single event under prefers-reduced-motion, where the rule switches to auto.

The height is the part of the code to understand before touching it. Every child of .spb-scene is absolutely positioned: without aspect-ratio the box would be 0 px tall, and with the ratio alone it would grow with its width — on a 1,280 px page, 790 px tall for 465 px of sold content (text, margins and padding), so scrollHeight = clientHeight, max = 0 and a rail frozen at 0%; that is what max-height: 340px prevents. Measured on a blank page with the sold text: at 1,280 px as at 960, the box is 340 px tall, for 125 px of travel (1,280) or 257 px (960); the cap stops acting below 544 px wide, where the ratio takes over — 290 px tall and 462 px of travel in a 480 px column, 234 px and 606 px on a 390 px phone. The rule to remember: the travel is scrollHeight − clientHeight, i.e. the text, its margins and the 44 px of padding minus the box height, and update() returns 0 as soon as it is no longer positive; with your own text, longer than six paragraphs, replace the cap with the height of your layout. Three more choices in the code: the font-family is declared once on .spb-scene (measured: Inter on the headings, the paragraphs and the pill, with no inline style at all); the dot's halo travels with it but both box-shadow values are constant, identical at 0% and at 100% — the position moves, not the intensity; and @media (prefers-reduced-motion: reduce) switches scroll-behavior to auto, which is what the demo copy announces. The resize listener on window, for its part, is never removed: if you destroy a scene dynamically, remove it too.

Accessibility

  • Reduced motion handled by a CSS rule@media (prefers-reduced-motion: reduce) { .spb-scroll { scroll-behavior: auto } }. Measured under reduce emulation: scrollTo(max) now fires a single scroll event instead of 21, and --spb-p follows the wheel identically. What moves follows the gesture 1:1, with no autonomous animation (WCAG 2.3.3 is met by construction); the only unrequested motion was the easing of programmatic scrolls, ≈ 170 ms, and that is what the rule removes.
  • Native scrollbar hidden and box not focusable: scrollbar-width: none removes the only scroll handle, and the rail, being aria-hidden, replaces it for no one. Measured in Chromium: Tab reaches .spb-scroll (keyboard-focusable scrollers since Chrome 130), Arrow down moves 40 px, Page down goes to the end; Safari and Firefox do not. Put tabindex="0", role="region" and an aria-label on .spb-scroll, with a visible :focus-visibleoverflow: hidden on the scene clips an outer outline, use outline-offset: -3px.
  • Screen readers: rail and pill are aria-hidden="true", nothing is announced and the progress does not exist for them. On a long article, expose it: role="progressbar", aria-valuemin="0", aria-valuemax="100" on .spb-pct (without aria-hidden), and aria-valuenow written in update() next to textContent. No aria-live: it would speak on every scroll event, 22 times for a single scrollTo.
  • Contrast on the rgb(12, 16, 34) background: headings 15.6:1, paragraphs 9.0:1, pill 9.4:1 on its 40% black, fill from 15.1:1 (mint) to 6.1:1 (blue), white dot 18.9:1 — all pass. The empty rail, white at 10%, is only 1.29:1: invisible to many, it leaves the 12 px pill as the only cue before the first scroll. That pill, at z-index: 3, covers 34 to 46 px of the text column's top-right corner (392 px free before the first heading at 480 px wide, 212 px at 300 px): a long heading runs underneath it.

Browser compatibility

ES5 code (var, function), one CSS variable read by two calc(), one { passive: true } listener. The real floor is aspect-ratio: without it, .spb-scene has no height at all, since every child is absolutely positioned; max-height only caps that height. scroll-behavior (Safari 15.4) and scrollbar-width (Chrome 121, Safari 18.2, covered before by ::-webkit-scrollbar) are cosmetic. The rail is driven by JavaScript, not by animation-timeline: it works in Firefox, which no version of scrolls natively in CSS — that is what sets it apart from Scroll Progress Bar (fx-0547) and Progress Bar (fx-0555). Zero dependencies: no library, no image, no font to load — the Inter, -apple-system, … stack falls back to the system font when Inter is not installed.

Chrome 88+✓ Full
Firefox 89+✓ Full
Safari 15+✓ Full (programmatic scrolls not eased before 15.4)
Edge 88+✓ Full
Mobile iOS 15+✓ Full (measured: 120 px swipe, 156 px with inertia → 28%)
Android Chrome 88+✓ Full

Without JavaScript, the text scrolls normally but the rail stays empty (--spb-p never written → 0), the dot at the top and the pill on "0%". Without aspect-ratio (Chrome < 88, Safari < 15), the scene is 0 px tall and disappears, since max-height only caps a height that exists: give it an explicit height.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="spb-scene">
  <div class="spb-beam" aria-hidden="true">
    <div class="spb-beam-fill">
    </div>
    <div class="spb-beam-dot">
    </div>
  </div>
  <div class="spb-pct" aria-hidden="true">0%</div>
  <div class="spb-scroll">
    <div class="spb-block">
      <h4>Chapitre 1 — Le principe</h4>
      <p>Scrollez à l'intérieur de cette zone : la barre lumineuse à gauche se remplit au rythme exact de votre lecture, avec un halo qui réagit à la position. Plus vous avancez, plus le faisceau monte.</p>
      <h4>Chapitre 2 — Le repère</h4>
      <p>Le curseur lumineux glisse le long de l'axe vertical. C'est un repère de progression élégant et discret, parfait pour un article long, une page de documentation ou un récit qui se déroule au scroll.</p>
      <h4>Chapitre 3 — La mécanique</h4>
      <p>Aucune dépendance externe : un simple écouteur de scroll calcule le ratio défilé / hauteur totale et met à jour une variable CSS. Léger, fluide, et entièrement accessible.</p>
      <h4>Chapitre 4 — La performance</h4>
      <p>L'écouteur est passif et ne touche qu'à une custom property, donc zéro reflow coûteux. Le rendu reste à 60 fps même sur mobile, et la barre suit le doigt au pixel près.</p>
      <h4>Chapitre 5 — L'accessibilité</h4>
      <p>La barre est purement décorative (aria-hidden) : le contenu reste pleinement lisible et navigable au clavier. En mode « mouvement réduit », le défilement instantané remplace le smooth-scroll.</p>
      <h4>Chapitre 6 — La fin</h4>
      <p>Arrivé tout en bas, la barre est pleine à 100% et le pourcentage l'indique. Un repère clair, du début à la fin, sans jamais voler l'attention au contenu.</p>
    </div>
  </div>
</div>
CSS
/* La hauteur vient du ratio 16/10 de la largeur, plafonnée à 340 px : sans plafond, sur une page large,
   la boîte devient plus haute que son contenu et il n'y a plus rien à faire défiler (rail bloqué à 0 %).
   Remplacez le plafond par la hauteur de votre mise en page (height: 480px, max-height: 70vh…). */
.spb-scene  {
   position: relative;
   width: 100%;
   aspect-ratio: 16 / 10;
   max-height: 340px;
   border-radius: 14px;
   overflow: hidden;
   isolation: isolate;
   background: rgb(12, 16, 34);
   font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
   }

.spb-scroll  {
   position: absolute;
   inset: 0px;
   overflow-y: auto;
   scroll-behavior: smooth;
   padding: 22px 22px 22px 40px;
   box-sizing: border-box;
   }

.spb-scroll::-webkit-scrollbar  {
   width: 0px;
   }

.spb-scroll  {
   scrollbar-width: none;
   }

/* Mouvement réduit : les défilements programmés (scrollTo, ancres, clavier) deviennent instantanés. */
@media (prefers-reduced-motion: reduce) {
   .spb-scroll  {
      scroll-behavior: auto;
      }
   }

.spb-block  {
   color: rgb(231, 233, 245);
   }

.spb-block h4  {
   margin: 0px 0px 8px;
   font-size: 16px;
   }

.spb-block p  {
   margin: 0px 0px 22px;
   font-size: 13px;
   line-height: 1.7;
   color: rgb(174, 178, 200);
   }

.spb-beam  {
   position: absolute;
   left: 14px;
   top: 18px;
   bottom: 18px;
   width: 4px;
   border-radius: 4px;
   background: rgba(255, 255, 255, 0.1);
   z-index: 3;
   pointer-events: none;
   }

.spb-beam-fill  {
   position: absolute;
   left: 0px;
   top: 0px;
   width: 100%;
   height: calc(var(--spb-p, 0) * 100%);
   border-radius: 4px;
   background: linear-gradient(rgb(92, 255, 216), rgb(109, 139, 255));
   box-shadow: rgba(109, 139, 255, 0.8) 0px 0px 12px, rgba(92, 255, 216, 0.9) 0px 0px 4px;
   }

.spb-beam-dot  {
   position: absolute;
   left: 50%;
   top: calc(var(--spb-p, 0) * 100%);
   width: 12px;
   height: 12px;
   border-radius: 50%;
   transform: translate(-50%, -50%);
   background: rgb(255, 255, 255);
   box-shadow: rgba(109, 139, 255, 0.9) 0px 0px 14px 3px;
   }

.spb-pct  {
   position: absolute;
   right: 14px;
   top: 12px;
   z-index: 3;
   font-size: 12px;
   font-variant-numeric: tabular-nums;
   color: rgb(174, 178, 200);
   background: rgba(0, 0, 0, 0.4);
   padding: 3px 9px;
   border-radius: 999px;
   pointer-events: none;
   }
JavaScript (fx-0793)
(function () {
    document.querySelectorAll('.spb-scene').forEach(function (scene) {
      var scroller = scene.querySelector('.spb-scroll');
      var beam = scene.querySelector('.spb-beam');
      var pct = scene.querySelector('.spb-pct');
      if (!scroller || !beam) return;
      function update() {
        var max = scroller.scrollHeight - scroller.clientHeight;
        var p = max > 0 ? scroller.scrollTop / max : 0;
        beam.style.setProperty('--spb-p', p.toFixed(4));
        if (pct) pct.textContent = Math.round(p * 100) + '%';
      }
      scroller.addEventListener('scroll', update, { passive: true });
      window.addEventListener('resize', update);
      update();
    });
  })();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Box height (CSS — .spb-scene { aspect-ratio: 16 / 10; max-height: 340px }) 16 / 10 of the width, capped at 340 px The scene's height, hence the available travel: below 544 px wide the ratio decides (234 px on a 390 px phone), above it the cap fixes 340 px and the sold text keeps 125 px of travel at 1,280 px. For your layout, replace both rules with a fixed height (height: 480px) or a relative one (max-height: 70vh); the rail always measures height − 36 px, and it only fills if the content overflows.
Rail position and thickness (CSS — .spb-beam { left: 14px; top: 18px; bottom: 18px; width: 4px }) 14 px from the left edge, 4 px For a right margin: right: 14px instead of left, and the padding of .spb-scroll mirrored (22px 40px 22px 22px). The rail is pointer-events: none: it never gets in the way of text selection.
Fill colours (CSS — .spb-beam-fill { background; box-shadow }) rgb(92, 255, 216) → rgb(109, 139, 255), shadows 12 px blue .8 + 4 px mint .9 The gradient and the two shadows are three colours to change together, plus the dot's halo (rgba(109, 139, 255, .9)) — four places for one hue. The empty rail (.spb-beam, white at 10%) is only 1.29:1 on the background: raise it to 18-22% if it must be seen before it fills.
Head dot (CSS — .spb-beam-dot { width/height: 12px; box-shadow: 0 0 14px 3px }) 12 px, 14 px / 3 px halo Centred on the top of the fill by translate(-50%, -50%), it overhangs the rail by 6 px above at 0% and below at 100%. display: none for a bare bar; at 16 px with an 18 px halo, it becomes an instrument.
Percentage pill (HTML — .spb-pct; JS — Math.round(p * 100) + '%') top right, 12 px, integer Remove the element: the JS checks if (pct) and carries on without it. Its width goes from 34 px ("0%") to 46 px ("100%") despite tabular-nums — anchored on the right, it grows towards the text; min-width: 46px; text-align: center keeps it steady.
Variable precision (JS — p.toFixed(4)) 4 decimals A step of 0.01%: on a 1,000 px rail, 0.1 px. Two decimals are enough and lighten the inline style; do not go down to zero, the rail would jump from empty to full.
Easing of programmatic scrolls (CSS — .spb-scroll { scroll-behavior: smooth }) smooth Only affects scrollTo, anchors and the keyboard — not the wheel or the finger. Measured: 125 px in ≈ 170 ms and 21 scroll events, hence 21 rail updates. Already auto under prefers-reduced-motion (rule included); set it to auto everywhere if the rail follows a stream of lines being appended.

FAQ

Why is the box capped at 340 px, and what happens if I remove the cap?

Because the rail only exists when there is something to scroll: update() computes max = scrollHeight − clientHeight and returns 0 as soon as the content fits in the box. Now the height of .spb-scene comes from aspect-ratio: 16 / 10, hence from its width: without the cap, on a blank 1,280 px page it would be 790 px tall for 465 px of content — scrollHeight = clientHeight, rail frozen at 0%, and that from 940 px wide with the sold text. max-height: 340px guarantees the travel on a wide page (125 px at 1,280 px, 257 px at 960) without touching small widths, where the ratio already gives a box shorter than its content. Remove it only if your content is clearly longer than six paragraphs, or replace it with the height of your layout. Check in the console: s.scrollHeight − s.clientHeight must be clearly positive.

How does it differ from Introduction (fx-0575) and Scroll Progress Bar (fx-0547)?

The maths is Introduction's — scrollTop / (scrollHeight − clientHeight) on a scrolling box — but everything else differs. Introduction writes a percentage width on a horizontal bar at the top of the column, targets a single container via getElementById and does no initial computation on load; this rail writes a CSS variable consumed by two calc() (fill height, dot position), installs itself on every .spb-scene on the page (measured: two instances at 0% and 25%, independent), also listens to resize, shows a percentage and lives in the margin without taking a line from the text. Scroll Progress Bar (fx-0547) is the other extreme: zero JavaScript, a horizontal bar animated by animation-timeline: scroll() — lighter, but motionless in Firefox, which implements that property in no version, and easy to mistake for the browser's loading bar.

How do I hook the rail to the whole page's scroll instead of a box?

Four changes. In the JS, replace scene.querySelector('.spb-scroll') with document.scrollingElement and put the listener on window (window.addEventListener('scroll', update, { passive: true })) — scrollTop, scrollHeight and clientHeight exist on scrollingElement with the same meaning. In the CSS, switch .spb-beam and .spb-pct to position: fixed (same left/top/bottom, they now anchor to the viewport) and move them out of .spb-sceneoverflow: hidden and isolation: isolate would clip or re-anchor them. Drop .spb-scroll, aspect-ratio, max-height and scrollbar-width: none: the page's native scrollbar stays, it is the one your readers grab. Keep resize, the travel changes on every resize. On mobile, the address bar collapsing changes clientHeight: the percentage shifts by 1 to 2 points without scrolling, which is expected.