ScrollGratuit

Scroll Progress Beam

Une barre de 4 px dans la marge gauche, remplie d'un dégradé menthe → bleu à hauteur de scrollTop / (scrollHeight − clientHeight), un point blanc de 12 px à sa tête et un pourcentage en haut à droite : une seule variable CSS, --spb-p, écrite par un écouteur de scroll passif, dans une boîte au ratio 16/10 plafonnée à 340 px pour qu'il reste toujours quelque chose à faire défiler.

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.

Survolez ou cliquez la scène pour interagir

Cet effet est gratuit — la catégorie Scroll en contient 70 au total, dont 12 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →

Exemples d'usage

LONGFORM Enquête
32 min de lecture · 8 000 mots
Littoral · Série en trois volets
Les derniers phares habités
Par Claire Aubry · Photographies Jules Marin

À la pointe du Raz, le gardien Yann Le Goff monte encore les 214 marches chaque soir. Il est l'un des onze derniers en France à vivre dans la tour qu'il entretient, et il sait que la relève ne viendra pas.

Une lumière que personne ne regarde plus

Depuis 1990, l'automatisation a vidé les phares un à un. Les optiques tournent seules, pilotées depuis Brest, et les cornes de brume se déclenchent au capteur. Les hommes sont partis, mais les bâtiments, eux, ont besoin de bras.

Le sel ronge les huisseries, le vent arrache les zincs, et une tour laissée un hiver sans visite perd dix ans en une saison. C'est ce que les ingénieurs des Phares et Balises appellent, sans ironie, la dette d'entretien.

Ce qu'il reste à transmettre

Yann a accepté que sa fille filme ses gestes : le graissage des roulements, la lecture du baromètre, la manière de caler la porte quand la tempête vient du sud-ouest. Cent heures d'images qu'aucune école ne demande.

Les collectivités hésitent. Un phare classé coûte 300 000 euros par décennie ; un phare ouvert au public en rapporte parfois le double, mais exige un accueil, un parking, des toilettes, et une route qui n'existe pas toujours.

En Écosse, le Northern Lighthouse Board a tranché : les tours sont louées à des familles qui s'engagent à les tenir. Le modèle intéresse la Bretagne, où quatre bâtiments seraient candidats dès l'an prochain.

Le soir de notre visite, la lumière s'est allumée à 18 h 42, exactement comme la veille. Yann a regardé sa montre, puis la mer, et il a dit qu'il faudrait bien que quelqu'un continue à regarder.

① Article de fond de 8 000 mots — application de lecture magazine

QuandVue de lecture d'une application éditoriale : en-tête avec le titre du magazine et la durée de lecture, puis la colonne de texte sur toute la hauteur, le rail dans la marge gauche.
PourquoiSur 8 000 mots, le lecteur veut savoir où il en est sans quitter la ligne qu'il lit ; le rail est à 14 px du bord, hors de la colonne, et ne couvre rien. Introduction (fx-0575) place sa barre horizontale au sommet de la colonne, dans la zone de contenu, où elle prend une ligne en plein écran. Ici la colonne est entière, et la pastille de 12 px tient dans le coin.
Réglagesaspect-ratio et max-height remplacés par la hauteur de la vue (rail = hauteur − 36 px), padding-left: 40px conservé pour le rail, corps en serif 13,5 px / 1,65, dégradé ambre #f6d28a → #e0862d et halos assortis, point conservé, pastille en 11 px.
ORION IV
Transat en solitaire · Les Sables-d'Olonne → Pointe-à-Pitre
JOUR 6 / 14

Jour 1 — 17 novembre

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

Largué les amarres à 9 h 10 sous un ciel bas. Le chenal était plein de monde, puis plus personne. La première heure, on ne pense qu'à ce qu'on a oublié.

Jour 2 — 18 novembre

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

Golfe de Gascogne, houle croisée. Deux ris dans la grand-voile avant la nuit. Le pilote tient bien, moi moins ; j'ai dormi par tranches de vingt minutes.

Jour 3 — 19 novembre

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

Cap Finisterre passé au large, à quarante milles. Le vent a tourné comme prévu et le bateau s'est enfin couché sur le bon bord. Premier café chaud sans le renverser.

Jour 4 — 20 novembre

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

Grand nord établi, spi de tempête toute la journée. Un cargo a croisé à trois milles sans répondre à la VHF. Le baromètre remonte, la mer se range doucement.

Jour 5 — 21 novembre

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

L'alizé s'installe. Réparé la drisse de spi ragée au capelage, une heure en tête de mât. De là-haut, le sillage est la seule ligne droite de l'océan.

Jour 6 — 22 novembre

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

Première nuit d'étoiles vraiment noire. Trois poissons volants sur le pont au réveil, un dans le cockpit. Il reste 2 300 milles et je n'ai plus envie d'arriver.

② Journal de bord d'une transat — le rail comme sonde de profondeur

QuandPage de récit d'une traversée en solitaire, un chapitre par jour : date, position, vent, puis le texte du jour ; l'en-tête affiche le bateau et le jour courant.
PourquoiLe point blanc et son halo évoquent un instrument de bord, et un rail vertical qui descend au fil des jours se lit comme une sonde : la lecture devient une descente dans le journal. Snap + Progress (fx-0597) donne une barre horizontale et des points par section — fonctionnel, sans image ; le rail, lui, porte le récit sans un pixel de plus.
RéglagesDégradé cyan → bleu profond #8ff0ff → #2f6bff, halo du point élargi à 0 0 18px 5px, rail épaissi à 5 px, positions et vent en chasse fixe, pastille conservée telle quelle (le pourcentage du journal), jour courant affiché par l'en-tête de la scène.
deploy-prod #4821 · main
build ✓ tests ✓ deploy ●
En cours · 2:33
112:04:01▶ Étape 1/3 — build
212:04:01$ git fetch --depth=1 origin main
312:04:02$ npm ci
412:04:191 284 paquets ajoutés en 17 s
512:04:19$ npm run build
612:04:41vite v6.0.3 · 214 modules transformés
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 terminé en 22,6 s
1012:04:42▶ Étape 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 ignoré
1612:04:59Fichiers de test 14 réussis (14)
1712:04:59Tests 231 réussis · 1 ignoré (232)
1812:04:59Couverture 91,4 % lignes · 84,2 % branches
1912:05:00▶ Étape 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:51En attente : 1 réplica sur 4 mis à jour disponible…
3312:06:04En attente : 2 réplicas sur 4 mis à jour disponibles…
3412:06:19En attente : 3 réplicas sur 4 mis à jour disponibles…
3512:06:33Sonde de disponibilité en attente sur api-7c9d4-x2k1q (1/2)
3612:06:34

③ Log d'un pipeline CI/CD — la complétion sans couvrir une ligne

QuandTableau de bord de supervision : nom du pipeline, étapes, puis le journal de build en chasse fixe qui s'allonge ; le rail dit où l'on en est dans le journal.
PourquoiChaque pixel de la zone centrale est une ligne de log ; une barre en haut de page (Scroll Progress Bar, fx-0547) se confond avec l'indicateur de chargement du navigateur et l'ingénieur ne sait plus laquelle il regarde. Un rail vertical dans la gouttière, à côté des numéros de ligne, ne ressemble à rien d'autre — et il reste rigoureusement 0 % / 100 % de la position de lecture, pas de l'avancement du job.
RéglagesDégradé vert #9ef7b8 → #22c55e (réussite), rail 3 px, point 10 px, pastille en chasse fixe sur le fond du terminal, lignes en 11,5 px, scroll-behavior: auto pour que le suivi des nouvelles lignes soit instantané.

Comment ça marche

Tout tient dans .spb-scene : position: relative, width: 100%, aspect-ratio: 16 / 10 plafonné par max-height: 340px (sa hauteur ne vient que de ces deux règles, tous ses enfants étant en absolu), overflow: hidden, isolation: isolate, fond rgb(12, 16, 34), coins de 14 px, et la pile de polices Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif déclarée une seule fois, héritée par les titres, les paragraphes et la pastille. Dedans, trois enfants en absolu. .spb-scroll (inset: 0, overflow-y: auto, padding: 22px 22px 22px 40px, scroll-behavior: smooth) porte le texte — six h4 de 16 px et six p de 13 px / 1,7 — et cache sa barre native par scrollbar-width: none doublé de ::-webkit-scrollbar { width: 0 } (mesuré : offsetWidth = clientWidth, aucune gouttière). .spb-beam, le rail : left: 14px, top: 18px, bottom: 18px, 4 px de large, blanc à 10 %, z-index: 3, pointer-events: none — il mesure donc la hauteur de la boîte moins 36 px (304 px sous le plafond). Son remplissage .spb-beam-fill a pour hauteur calc(var(--spb-p, 0) * 100%), un dégradé rgb(92, 255, 216) → rgb(109, 139, 255) et deux ombres (12 px bleu à .8, 4 px menthe à .9) ; le point .spb-beam-dot, 12 px blanc, halo 0 0 14px 3px, est posé à top: calc(var(--spb-p, 0) * 100%) et recentré par translate(-50%, -50%). .spb-pct, la pastille (right: 14px, top: 12px, 12 px, tabular-nums, noir à 40 %), affiche le pourcentage.

Le JavaScript est une IIFE ES5 qui traite chaque .spb-scene de la page (mesuré avec deux instances : 0 % et 25 %, indépendantes). Sa fonction update() calcule max = scrollHeight − clientHeight, puis p = scrollTop / max (0 si max ≤ 0), écrit --spb-p sur le rail avec toFixed(4) et le texte Math.round(p × 100) + '%' dans la pastille — si elle existe. Elle est appelée une fois à l'initialisation, à chaque scroll de la boîte ({ passive: true }) et à chaque resize de la fenêtre. Aucune transition, aucun lissage : le rail suit le défilement image par image. Mesuré dans Chromium sur une page vierge de 1 280 px, boîte de 1 264 × 340 px, course de 125 px : un cran de molette de 60 px donne 0.4800 → « 48% », 145,9 px de remplissage sur un rail de 304 px et le centre du point exactement à 145,9 px ; 0.9600 au deuxième cran, 1.0000 au troisième, « 100% », le point dépassant de 6 px sous le rail (c'est le translate) ; molette inverse, retour à 0.0000. 200 appels de update() coûtent 0,2 ms. scroll-behavior: smooth ne concerne que les défilements programmés : scrollTo(max) déclenche 21 événements scroll et atteint 1 en ≈ 170 ms — un seul événement sous prefers-reduced-motion, où la règle passe en auto.

La hauteur est la partie du code à comprendre avant d'y toucher. Tous les enfants de .spb-scene sont en absolu : sans aspect-ratio, la boîte ferait 0 px, et avec le ratio seul elle grandirait avec sa largeur — sur une page de 1 280 px, 790 px de haut pour 465 px de contenu vendu (texte, marges et padding), donc scrollHeight = clientHeight, max = 0 et un rail figé à 0 % ; c'est ce que max-height: 340px empêche. Mesuré sur une page vierge avec le texte vendu : à 1 280 px comme à 960, la boîte fait 340 px de haut, pour 125 px de course (1 280) ou 257 px (960) ; le plafond cesse d'agir sous 544 px de large, où le ratio reprend la main — 290 px de haut et 462 px de course dans une colonne de 480 px, 234 px et 606 px sur un mobile de 390 px. La règle à retenir : la course vaut scrollHeight − clientHeight, soit le texte, ses marges et les 44 px de padding moins la hauteur de la boîte, et update() renvoie 0 dès qu'elle n'est plus positive ; avec votre propre texte, plus long que six paragraphes, remplacez le plafond par la hauteur de votre mise en page. Trois autres choix du code : la font-family est déclarée une seule fois sur .spb-scene (mesuré : Inter sur les titres, les paragraphes et la pastille, sans aucun style en ligne) ; le halo du point voyage avec lui mais ses deux box-shadow sont constantes, identiques à 0 % et à 100 % — c'est la position qui bouge, pas l'intensité ; et @media (prefers-reduced-motion: reduce) passe scroll-behavior en auto, ce que le texte de la démo annonce. L'écouteur resize sur window, lui, n'est jamais retiré : si vous détruisez une scène dynamiquement, retirez-le avec.

Accessibilité

  • Mouvement réduit pris en charge par une règle CSS@media (prefers-reduced-motion: reduce) { .spb-scroll { scroll-behavior: auto } }. Mesuré sous émulation reduce : scrollTo(max) ne déclenche plus qu'un seul événement scroll au lieu de 21, et --spb-p suit la molette à l'identique. Ce qui bouge suit le geste 1:1, sans animation autonome (WCAG 2.3.3 est respecté par construction) ; le seul mouvement non demandé était le lissage des défilements programmés, ≈ 170 ms, et c'est lui que la règle retire.
  • Barre native masquée et boîte non focalisable : scrollbar-width: none retire la seule poignée de défilement, et le rail, en aria-hidden, ne la remplace pour personne. Mesuré dans Chromium : Tab atteint .spb-scroll (boîtes défilantes focalisables au clavier depuis Chrome 130), Flèche bas fait 40 px, Page suivante va au bout ; Safari et Firefox ne le font pas. Posez tabindex="0", role="region" et un aria-label sur .spb-scroll, avec un :focus-visible visible — overflow: hidden sur la scène coupe un contour extérieur, utilisez outline-offset: -3px.
  • Lecteurs d'écran : rail et pastille sont aria-hidden="true", rien n'est annoncé et la progression n'existe pas pour eux. Sur un article long, exposez-la : role="progressbar", aria-valuemin="0", aria-valuemax="100" sur .spb-pct (sans aria-hidden), et aria-valuenow écrit dans update() à côté de textContent. Pas d'aria-live : il parlerait à chaque événement scroll, 22 fois pour un seul scrollTo.
  • Contrastes sur le fond rgb(12, 16, 34) : titres 15,6:1, paragraphes 9,0:1, pastille 9,4:1 sur son noir à 40 %, remplissage de 15,1:1 (menthe) à 6,1:1 (bleu), point blanc 18,9:1 — tout passe. Le rail vide, blanc à 10 %, ne fait que 1,29:1 : invisible pour beaucoup, il laisse la pastille de 12 px comme seul repère avant le premier défilement. Cette pastille, en z-index: 3, couvre 34 à 46 px du coin haut-droit de la colonne de texte (392 px libres devant le premier titre à 480 px de large, 212 px à 300 px) : un titre long passe dessous.

Compatibilité navigateur

Code ES5 (var, function), une variable CSS lue par deux calc(), un écouteur { passive: true }. Le plancher réel est aspect-ratio : sans lui, .spb-scene n'a aucune hauteur, tous ses enfants étant en absolu ; max-height ne fait que plafonner cette hauteur. scroll-behavior (Safari 15.4) et scrollbar-width (Chrome 121, Safari 18.2, relayé avant par ::-webkit-scrollbar) sont cosmétiques. Le rail est piloté par JavaScript, pas par animation-timeline : il fonctionne dans Firefox, qu'aucune version ne fait défiler en CSS natif — c'est ce qui le sépare de Scroll Progress Bar (fx-0547) et Progress Bar (fx-0555). Zéro dépendance : aucune bibliothèque, aucune image, aucune police à charger — la pile Inter, -apple-system, … retombe sur la police système si Inter n'est pas installée.

Chrome 88+✓ Complet
Firefox 89+✓ Complet
Safari 15+✓ Complet (défilements programmés sans lissage avant 15.4)
Edge 88+✓ Complet
Mobile iOS 15+✓ Complet (mesuré : glissé de 120 px, 156 px avec l'inertie → 28 %)
Android Chrome 88+✓ Complet

Sans JavaScript, le texte défile normalement mais le rail reste vide (--spb-p jamais écrit → 0), le point en haut et la pastille sur « 0% ». Sans aspect-ratio (Chrome < 88, Safari < 15), la scène a 0 px de haut et disparaît, max-height ne plafonnant qu'une hauteur qui existe : donnez-lui une height explicite.

Le code

Copiez les trois blocs dans votre page. Aucune dépendance.

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();
    });
  })();

Personnaliser

Options passées à l'API ou attributs data-* :

Option / propriétéDéfautEffet
Hauteur de la boîte (CSS — .spb-scene { aspect-ratio: 16 / 10; max-height: 340px }) 16 / 10 de la largeur, plafonné à 340 px La hauteur de la scène, donc la course disponible : sous 544 px de large le ratio décide (234 px sur un mobile de 390 px), au-delà le plafond fixe 340 px et le texte vendu garde 125 px de course à 1 280 px. Pour votre mise en page, remplacez les deux règles par une hauteur fixe (height: 480px) ou relative (max-height: 70vh) ; le rail mesure toujours la hauteur − 36 px, et il ne se remplit que si le contenu déborde.
Position et épaisseur du rail (CSS — .spb-beam { left: 14px; top: 18px; bottom: 18px; width: 4px }) 14 px du bord gauche, 4 px Pour une marge droite : right: 14px à la place de left, et le padding de .spb-scroll inversé (22px 40px 22px 22px). Le rail est en pointer-events: none : il ne gêne jamais la sélection du texte.
Couleurs du remplissage (CSS — .spb-beam-fill { background; box-shadow }) rgb(92, 255, 216) → rgb(109, 139, 255), ombres 12 px bleu .8 + 4 px menthe .9 Le dégradé et les deux ombres font trois couleurs à changer ensemble, plus le halo du point (rgba(109, 139, 255, .9)) — quatre endroits pour une teinte. Le rail vide (.spb-beam, blanc à 10 %) ne fait que 1,29:1 sur le fond : montez-le à 18-22 % s'il doit se voir avant de se remplir.
Point de tête (CSS — .spb-beam-dot { width/height: 12px; box-shadow: 0 0 14px 3px }) 12 px, halo 14 px / 3 px Centré sur le sommet du remplissage par translate(-50%, -50%), il dépasse de 6 px au-dessus du rail à 0 % et en dessous à 100 %. display: none pour une barre nue ; à 16 px avec un halo de 18 px, il devient un instrument.
Pastille de pourcentage (HTML — .spb-pct ; JS — Math.round(p * 100) + '%') en haut à droite, 12 px, entier Supprimez l'élément : le JS teste if (pct) et continue sans lui. Sa largeur passe de 34 px (« 0% ») à 46 px (« 100% ») malgré tabular-nums — ancrée à droite, elle grandit vers le texte ; min-width: 46px; text-align: center la stabilise.
Précision de la variable (JS — p.toFixed(4)) 4 décimales Un pas de 0,01 % : sur un rail de 1 000 px, 0,1 px. Deux décimales suffisent et allègent le style en ligne ; ne descendez pas à zéro, le rail sauterait de vide à plein.
Lissage des défilements programmés (CSS — .spb-scroll { scroll-behavior: smooth }) smooth N'agit que sur scrollTo, les ancres et le clavier — pas sur la molette ni le doigt. Mesuré : 125 px en ≈ 170 ms et 21 événements scroll, donc 21 mises à jour du rail. Déjà auto sous prefers-reduced-motion (règle incluse) ; passez-le en auto partout si le rail suit un flux de lignes qui s'ajoutent.

FAQ

Pourquoi la boîte est-elle plafonnée à 340 px, et que se passe-t-il si je retire ce plafond ?

Parce que le rail n'existe que s'il y a quelque chose à faire défiler : update() calcule max = scrollHeight − clientHeight et renvoie 0 dès que le contenu tient dans la boîte. Or la hauteur de .spb-scene vient d'aspect-ratio: 16 / 10, donc de sa largeur : sans plafond, sur une page vierge de 1 280 px, elle ferait 790 px de haut pour 465 px de contenu — scrollHeight = clientHeight, rail figé à 0 %, et ce dès 940 px de large avec le texte vendu. max-height: 340px garantit la course sur une page large (125 px à 1 280 px, 257 px à 960) sans toucher aux petites largeurs, où le ratio donne déjà une boîte plus basse que son contenu. Retirez-le seulement si votre contenu est franchement plus long que six paragraphes, ou remplacez-le par la hauteur de votre mise en page. Vérifiez dans la console : s.scrollHeight − s.clientHeight doit être franchement positif.

Quelle différence avec Introduction (fx-0575) et Scroll Progress Bar (fx-0547) ?

Le calcul est celui d'Introduction — scrollTop / (scrollHeight − clientHeight) sur une boîte qui défile —, mais tout le reste diffère. Introduction écrit une width en pourcentage sur une barre horizontale posée au sommet de la colonne, cible un seul conteneur par getElementById et ne fait aucun premier calcul au chargement ; ce rail écrit une variable CSS que deux calc() consomment (hauteur du remplissage, position du point), s'installe sur chaque .spb-scene de la page (mesuré : deux instances à 0 % et 25 %, indépendantes), écoute aussi resize, affiche un pourcentage et vit dans la marge sans prendre une ligne au texte. Scroll Progress Bar (fx-0547) est l'autre extrême : zéro JavaScript, une barre horizontale animée par animation-timeline: scroll() — plus léger, mais immobile dans Firefox, qui n'implémente cette propriété dans aucune version, et confondable avec la barre de chargement du navigateur.

Comment brancher le rail sur le défilement de la page entière plutôt que sur une boîte ?

Quatre changements. Dans le JS, remplacez scene.querySelector('.spb-scroll') par document.scrollingElement et posez l'écouteur sur window (window.addEventListener('scroll', update, { passive: true })) — scrollTop, scrollHeight et clientHeight existent sur scrollingElement avec le même sens. Dans le CSS, passez .spb-beam et .spb-pct en position: fixed (mêmes left/top/bottom, ils se calent sur la fenêtre) et sortez-les de .spb-sceneoverflow: hidden et isolation: isolate les couperaient ou les recadreraient. Supprimez .spb-scroll, aspect-ratio, max-height et scrollbar-width: none : la barre native de la page reste, c'est elle que vos lecteurs attrapent. Gardez resize, la course change à chaque redimensionnement. Sur mobile, la barre d'adresse qui se rétracte modifie clientHeight : le pourcentage bouge de 1 à 2 points sans défilement, c'est normal.