Le Livre

Règle de défilement

Une colonne de graduations qui dit la position, la part visible et le découpage du contenu.

Le pavé gris

Une barre de défilement répond à deux questions : où j'en suis, et combien il reste. Celle du système répond aux deux avec le même rectangle, et n'en dit pas une de plus. Elle a le mérite d'exister partout, et c'est bien pour ça qu'on ne la remplace qu'en connaissance de cause.

La mesure

Ici, la longueur de chaque trait est fonction de sa distance au point de lecture. Le renflement est le curseur, sa largeur est la part de contenu visible. Aucun trait n'est décoratif : retirez-en un, et la mesure est fausse.

Les frontières

Les traits un peu plus longs et un peu plus foncés tombent sur un début de section. C'est un plancher, pas un état : le renflement passe par-dessus sans les effacer. Deux repères de même force ne se liraient plus comme une hiérarchie.

Le repos

Passé une seconde et demie sans geste, la règle s'efface. Le réveil est rapide, l'endormissement lent : une barre de défilement doit arriver au moment du geste et repartir sans qu'on la voie partir. Une fois endormie, la boucle d'animation s'arrête pour de bon.

La prise

Saisir le renflement, c'est tenir le contenu : le suivi est au pixel dès la première image. Cliquer ailleurs est une intention de lecture, pas une téléportation : la boîte y va en glissant. Trois pixels de mouvement séparent les deux gestes.

Ce qui reste au système

En mode page, la règle ne remplace la barre native qu'au-delà de 1120 pixels, avec un pointeur fin. Au doigt, sur petit écran ou sans JavaScript, le système garde la main. On ne retire jamais un moyen de défiler sans en poser un autre qui marche.

Molette, clic, glissé. Les traits longs marquent les sections.

Fiche

Chapitre
Retour
Nom courant
Scrollbar
Publié le
1er septembre 2026
Fichiers
Rail.tsx, Rail.css
Dépendances
Aucune

Pourquoi

Une barre de défilement répond à deux questions : où j'en suis, et combien il reste. Celle du système répond aux deux avec le même rectangle gris, et n'en dit pas une de plus. Elle a le mérite d'exister partout, sur tous les appareils, et c'est exactement pour ça qu'on ne la remplace qu'en connaissance de cause.

Celle-ci répond aux deux mêmes questions, et à une troisième. La longueur de chaque graduation est fonction de sa distance au point de lecture : le renflement EST le curseur, sa largeur EST la part de contenu visible. Les traits posés sur un début de section partent d'un peu plus loin et pèsent un peu plus lourd, ce qui donne le découpage du document en même temps que la position. Rien n'est décoratif : retirez un trait, et la mesure est fausse.

Rail.tsxL269–280
const t = i / (n - 1);
let k = 1 - ease(clamp01(Math.abs(t - pos) / reach));
if (lit) {
  const h =
    (1 - ease(clamp01(Math.abs(t - lensY) / LENS))) * lens * LENS_GAIN;
  if (h > k) k = h;
}
/* Le trait de section part de plus loin : même loi, autre plancher. */
const s0 = mark[i] ? MARK_SCALE : SCALE_MIN;
const a0 = mark[i] ? MARK_ALPHA : ALPHA_MIN;
const s = s0 + (1 - s0) * k;
const a = a0 + (ALPHA_MAX - a0) * k;
Une seule loi, deux planchers. La distance au point de lecture donne la longueur et l'opacité ; un début de section ne change que le point de départ.

Elle mesure la page entière ou n'importe quelle boîte défilée. C'est la seule chose que change la propriété scroller : cinq fonctions d'adaptation en tête d'effet, et tout le reste du composant ignore lequel des deux il est en train de mesurer.

Rail.tsxL117–132
const pane = scroller?.current ?? null;
const body = pane ?? document.scrollingElement ?? root;
const source: EventTarget = pane ?? window;
const seen = () => (pane ? pane.clientHeight : window.innerHeight);
const here = () => (pane ? pane.scrollTop : window.scrollY);
const jump = (y: number, glide: boolean) => {
  const how: ScrollToOptions = {
    top: y,
    behavior: glide ? "smooth" : "instant",
  };
  if (pane) pane.scrollTo(how);
  else window.scrollTo(how);
};
const offset = (el: Element) =>
  el.getBoundingClientRect().top +
  (pane ? pane.scrollTop - pane.getBoundingClientRect().top : window.scrollY);
Les cinq fonctions. Passé elles, plus une ligne du composant ne sait s'il mesure la page ou une boîte.

Le rendu tient en deux propriétés. Chaque trait porte sa longueur pleine dans le DOM et n'est jamais touché qu'en scaleX et en opacity, les deux seules choses que le compositeur sait traiter sans repasser par la mise en page. Aucune mesure de mise en page n'est faite pendant le geste, et la boucle d'animation s'arrête d'elle-même dès que la position lissée a rejoint sa cible : une barre au repos ne coûte rien.

Rail.tsxL281–288
if (Math.abs(s - sx[i]) > 0.0015) {
  sx[i] = s;
  ticks[i].style.transform = `scaleX(${s.toFixed(4)})`;
}
if (Math.abs(a - al[i]) > 0.003) {
  al[i] = a;
  ticks[i].style.opacity = a.toFixed(3);
}
Les deux tampons. Sans eux, quarante affectations de style par trame pour des variations que personne ne voit.

La version qui tourne sur ce site va un cran plus loin : le clic y ouvre une rampe de défilement maison plutôt qu'un scrollTo lissé par le navigateur, ce qui permet de rattraper le doigt sans que la page saute quand un clic se requalifie en glissement. C'est une trentaine de lignes de plus, et une dépendance. La planche s'arrête avant : elle doit tenir dans deux fichiers qu'on copie.

Ce qui coince

  • Un trait d'un pixel se pose sur un pixel entier. Une graduation dont l'ordonnée est fractionnaire est rendue en gris sur deux rangées au lieu d'être nette sur une. Chaque position est arrondie, sinon la moitié de la règle est floue et personne ne sait dire pourquoi.
    Rail.tsxL168–173
    const place = () => {
      const span = Math.max(0, height - 1);
      for (let i = 0; i < n; i++) {
        ticks[i].style.top = `${Math.round((i * span) / (n - 1))}px`;
      }
    };
    
  • Le lissage doit être exponentiel. pos += (aim - pos) * 0.2 accélère avec la fréquence de l'écran : la même règle est molle à 60 Hz et sèche à 144 Hz. La forme 1 - e^(-dt/τ) donne le même mouvement partout, et c'est un seul appel à Math.exp par trame.
    Rail.tsxL301–304
    const kp = calm.matches || drag ? 1 : 1 - Math.exp(-dt / TAU_POS);
    const kl = calm.matches ? 1 : 1 - Math.exp(-dt / TAU_LENS);
    pos += (aim - pos) * kp;
    lens += (lensAim - lens) * kl;
    
  • behavior: "instant", jamais "auto". auto veut dire « suis le scroll-behavior du CSS », qui vaut souvent smooth. Pendant un glissement, ça transforme le suivi au pixel en caoutchouc. Le mot qui force l'instantané est instant.
    Rail.tsxL122–129
    const jump = (y: number, glide: boolean) => {
      const how: ScrollToOptions = {
        top: y,
        behavior: glide ? "smooth" : "instant",
      };
      if (pane) pane.scrollTo(how);
      else window.scrollTo(how);
    };
    
  • Trois pixels séparent un clic d'un glissement. En deçà, la main qui appuie tremble, elle ne glisse pas. Sans ce seuil, chaque clic devient un micro-glissement et coupe la glissade qu'on vient de lancer.
    Rail.tsxL384–390
    if (!drag) {
      /* Trois pixels : au-dessous, la main qui appuie tremble, elle ne
         glisse pas. Requalifier un tremblement en glissement couperait
         la glissade qu'on vient de lancer. */
      if (Math.abs(e.clientY - anchor) < SLOP) return;
      drag = true;
    }
    
  • Une boîte défilée ne grandit pas avec son contenu. Un ResizeObserver posé sur le conteneur ne verra jamais rien : c'est sa boîte qui est observée, pas son contenu. Il faut observer les enfants. Sur la page entière, observer documentElement suffit.
  • Elle ne s'annonce pas comme une barre. aria-hidden est délibéré : le défilement au clavier et aux technologies d'assistance passe par le document. Exposer un second widget « scrollbar » n'ajouterait qu'un doublon dans l'arbre d'accessibilité. Et le CSS ne l'affiche qu'au-delà de 1120 px avec un pointeur fin, là où la barre native peut disparaître sans rien retirer à personne.
    Rail.cssL30–35
    @media (min-width: 70rem) and (min-height: 34rem) and (hover: hover) and
      (pointer: fine) {
      .rle[data-page="true"] {
        display: block;
      }
    }
    

Le code

Les fichiers en entier, lus sur le disque au moment du build, dans le composant même qui tourne au-dessus. Les extraits cités plus haut en sont des lignes exactes : les numéros correspondent. Copiez-les, le composant n'a besoin de rien d'autre.

"use client";

/* ------------------------------------------------------------------
   Règle de défilement.

   Le navigateur n'a qu'un mot pour dire « où vous en êtes » : un pavé
   gris. Celle-ci le dit autrement : une colonne de graduations d'un
   pixel, et un renflement là où se trouve la lecture. La longueur de
   chaque trait est fonction de sa distance au point de lecture : le
   renflement EST le curseur, sa largeur EST la part visible. Les traits
   posés sur un début de section sortent un peu plus et pèsent un peu
   plus lourd : le découpage du document se lit en même temps que la
   position. Rien n'est décoratif, tout est une mesure.

   Elle mesure la page entière, ou n'importe quelle boîte défilée. C'est
   la seule chose que `scroller` change : passé les cinq fonctions
   d'adaptation en tête d'effet, le composant ne sait plus lequel des
   deux il mesure.

   Quatre règles de fabrication :

   1. Aucune mise en page n'est animée. Chaque trait porte sa longueur
      finale dans le DOM et n'est jamais touché qu'en `scaleX` et en
      `opacity` : les deux seules propriétés que le compositeur sait
      traiter sans repasser par la mise en page.
   2. La boucle s'arrête. Dès que la position lissée a rejoint sa cible
      et que la règle s'est endormie, `requestAnimationFrame` n'est plus
      redemandé : une barre au repos ne doit rien coûter.
   3. Le lissage est exponentiel, pas linéaire (`1 - e^(-dt/τ)`), donc
      identique à 60, 120 ou 144 Hz. Un lissage à pas fixe accélère avec
      la fréquence de l'écran ; celui-ci non.
   4. Une écriture n'est faite que si la valeur a bougé. Les deux tampons
      `sx` / `al` évitent quarante affectations de style par trame pour
      des variations invisibles.

   Elle ne remplace la barre du système que là où le CSS le dit : en mode
   page, au-delà de 1120 px et avec un pointeur fin. En dessous (au
   doigt, sur petit écran, sans JS), la barre native reste en place. On
   ne retire jamais un moyen de défiler sans en poser un autre qui
   marche, et c'est le CSS qui tranche : le composant lit `display` au
   lieu de dupliquer le seuil.

   `aria-hidden` est délibéré. Le défilement au clavier et aux
   technologies d'assistance passe par le document, jamais par ce
   composant : exposer un second widget « scrollbar » n'ajouterait qu'un
   doublon dans l'arbre d'accessibilité.
------------------------------------------------------------------- */

import { useEffect, useRef } from "react";

const PITCH = 7; /* pas entre deux graduations, en px */
const TICKS_MIN = 14;
const TICKS_MAX = 76;

const SCALE_MIN = 0.26; /* longueur au repos, en fraction de la pleine */
const ALPHA_MIN = 0.15;
const ALPHA_MAX = 0.88;

/* Planchers des traits posés sur un début de section. Un plancher, pas
   un état : le renflement du curseur passe par-dessus sans rien
   effacer, et deux repères de même force ne se liraient plus comme une
   hiérarchie. */
const MARK_SCALE = 0.42;
const MARK_ALPHA = 0.3;

const FEATHER = 0.055; /* dégradé de part et d'autre du curseur */
const THUMB_MIN = 0.06; /* le curseur ne disparaît jamais tout à fait */
const THUMB_MAX = 0.24; /* ni n'avale la règle sur un contenu court */

const PROX = 150; /* rayon d'attraction du pointeur, en px */
const LENS = 0.1; /* demi-largeur de la loupe du pointeur */
const LENS_GAIN = 0.58; /* large et basse, là où le curseur est étroit et
                           haut : la loupe éclaire, elle ne prétend pas
                           indiquer une position. */

const IDLE = 1600; /* ms d'immobilité avant le repos */
const FIRST_IDLE = 2900; /* la première fois, le temps de la remarquer */
const BOOT = 640; /* entrée décalée : elle arrive après la page, pas avec */
const TAU_POS = 55; /* constantes de lissage, en ms */
const TAU_LENS = 110;
const SLOP = 3; /* px avant qu'un clic devienne un glissement */
const DEAD = 24; /* en deçà, il n'y a plus rien à mesurer */

const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
const ease = (x: number) => x * x * (3 - 2 * x);

export type RailProps = {
  /** La boîte défilée. Absente : la page entière. */
  scroller?: React.RefObject<HTMLElement | null>;
  /** Sélecteur des débuts de section, relevés dans le contenu défilé.
      Vide : pas de repères, seulement la position. */
  sections?: string;
  className?: string;
};

export default function Rail({
  scroller,
  sections = "section",
  className,
}: RailProps) {
  const rail = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const hit = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const host = rail.current;
    const rack = track.current;
    const grip = hit.current;
    if (!host || !rack || !grip) return;

    const root = document.documentElement;
    const calm = window.matchMedia("(prefers-reduced-motion: reduce)");

    /* ---------------- adaptation ----------------
       Cinq fonctions, et la page comme la boîte répondent aux mêmes
       questions. Tout ce qui suit les ignore. */
    const pane = scroller?.current ?? null;
    const body = pane ?? document.scrollingElement ?? root;
    const source: EventTarget = pane ?? window;
    const seen = () => (pane ? pane.clientHeight : window.innerHeight);
    const here = () => (pane ? pane.scrollTop : window.scrollY);
    const jump = (y: number, glide: boolean) => {
      const how: ScrollToOptions = {
        top: y,
        behavior: glide ? "smooth" : "instant",
      };
      if (pane) pane.scrollTo(how);
      else window.scrollTo(how);
    };
    const offset = (el: Element) =>
      el.getBoundingClientRect().top +
      (pane ? pane.scrollTop - pane.getBoundingClientRect().top : window.scrollY);

    let ticks: HTMLSpanElement[] = [];
    let sx = new Float32Array(0);
    let al = new Float32Array(0);
    let mark = new Uint8Array(0); /* 1 = début de section */
    let n = 0;

    let live = false; /* le CSS a-t-il posé la règle ? */
    let top = 0;
    let height = 0;
    let box = { left: 0, right: 0 };
    let max = 0; /* course de défilement */
    let half = THUMB_MIN / 2; /* demi-hauteur du curseur, normalisée */

    let aim = 0; /* centre visé, normalisé sur la règle */
    let pos = 0; /* centre lissé */
    let lens = 0; /* présence du pointeur, 0..1 */
    let lensAim = 0;
    let lensY = 0;

    let placed = false;
    let armed = false; /* bouton enfoncé, pas encore un glissement */
    let drag = false;
    let hold = 0; /* écart curseur → point de saisie, conservé au drag */
    let anchor = 0; /* ordonnée de la saisie, pour mesurer le seuil */
    let frame = 0;
    let stamp = 0;
    let act = 0;
    let awake = false;
    let first = true;

    /* ---------------- géométrie ---------------- */

    /* Les traits font un pixel : une position fractionnaire les rendrait
       gris au lieu de nets. Chaque graduation est arrondie au pixel. */
    const place = () => {
      const span = Math.max(0, height - 1);
      for (let i = 0; i < n; i++) {
        ticks[i].style.top = `${Math.round((i * span) / (n - 1))}px`;
      }
    };

    const build = () => {
      const want = Math.min(
        TICKS_MAX,
        Math.max(TICKS_MIN, Math.round(height / PITCH))
      );
      if (want === n && ticks.length) {
        place();
        return;
      }
      n = want;
      const frag = document.createDocumentFragment();
      ticks = [];
      for (let i = 0; i < n; i++) {
        const el = document.createElement("span");
        el.className = "rle__tick";
        frag.appendChild(el);
        ticks.push(el);
      }
      rack.replaceChildren(frag);
      sx = new Float32Array(n).fill(-1);
      al = new Float32Array(n).fill(-1);
      place();
    };

    /* Les frontières sont relevées, jamais devinées : chaque section est
       ramenée à sa fraction de contenu, puis à la graduation la plus
       proche. Deux sections trop rapprochées tombent sur le même trait,
       et c'est honnête : la règle n'a pas la résolution de les séparer,
       elle n'a pas à faire semblant. */
    const survey = () => {
      mark = new Uint8Array(n);
      const inner = body.scrollHeight;
      if (sections && inner > 0 && n > 1) {
        (pane ?? document).querySelectorAll(sections).forEach((el) => {
          const i = Math.round(clamp01(offset(el) / inner) * (n - 1));
          /* La première graduation est ignorée : le haut du contenu n'est
             pas une frontière, il est le début. */
          if (i > 0) mark[i] = 1;
        });
      }
      for (let i = 0; i < n; i++) {
        if (mark[i]) ticks[i].dataset.mark = "";
        else delete ticks[i].dataset.mark;
      }
      /* Le plancher a bougé sous les tampons : la prochaine trame doit
         réécrire, même si la position n'a pas varié d'un poil. */
      sx.fill(-1);
      al.fill(-1);
    };

    const read = () => {
      const p = max > 0 ? clamp01(here() / max) : 0;
      aim = half + p * (1 - 2 * half);
    };

    const measure = () => {
      live = getComputedStyle(host).display !== "none";
      if (!live) return;

      const r = host.getBoundingClientRect();
      top = r.top;
      height = r.height;
      box = { left: r.left, right: r.right };
      if (height <= 0) return;

      const inner = body.scrollHeight;
      const view = seen();
      max = Math.max(0, inner - view);
      /* Le curseur garde la sémantique de la barre native : sa hauteur est
         la part de contenu visible, bornée pour rester lisible. */
      const ratio = inner > 0 ? clamp01(view / inner) : 1;
      half = Math.min(THUMB_MAX, Math.max(THUMB_MIN, ratio)) / 2;

      host.dataset.scroll = max > DEAD ? "true" : "false";
      /* Le contenu vient de cesser de défiler : une règle qui reste
         allumée devant une boîte qui tient dans l'écran ne mesure plus
         rien. */
      if (max <= DEAD) sleep();
      build();
      survey();
      read();
      if (!placed) {
        pos = aim;
        placed = true;
      }
      paint();
    };

    /* ---------------- rendu ---------------- */

    const paint = () => {
      const reach = half + FEATHER;
      const lit = lens > 0.002;
      for (let i = 0; i < n; i++) {
        const t = i / (n - 1);
        let k = 1 - ease(clamp01(Math.abs(t - pos) / reach));
        if (lit) {
          const h =
            (1 - ease(clamp01(Math.abs(t - lensY) / LENS))) * lens * LENS_GAIN;
          if (h > k) k = h;
        }
        /* Le trait de section part de plus loin : même loi, autre plancher. */
        const s0 = mark[i] ? MARK_SCALE : SCALE_MIN;
        const a0 = mark[i] ? MARK_ALPHA : ALPHA_MIN;
        const s = s0 + (1 - s0) * k;
        const a = a0 + (ALPHA_MAX - a0) * k;
        if (Math.abs(s - sx[i]) > 0.0015) {
          sx[i] = s;
          ticks[i].style.transform = `scaleX(${s.toFixed(4)})`;
        }
        if (Math.abs(a - al[i]) > 0.003) {
          al[i] = a;
          ticks[i].style.opacity = a.toFixed(3);
        }
      }
    };

    const tick = (now: number) => {
      frame = 0;
      const dt = stamp ? Math.min(64, now - stamp) : 16;
      stamp = now;

      /* Sous `prefers-reduced-motion`, la règle colle à la position réelle :
         le lissage est un agrément, pas une information. Pendant un
         glissement aussi : une rampe entre le doigt et la page, même de
         trente millisecondes, se sentirait comme du caoutchouc. */
      const kp = calm.matches || drag ? 1 : 1 - Math.exp(-dt / TAU_POS);
      const kl = calm.matches ? 1 : 1 - Math.exp(-dt / TAU_LENS);
      pos += (aim - pos) * kp;
      lens += (lensAim - lens) * kl;

      paint();

      if (awake && now - act > (first ? FIRST_IDLE : IDLE)) sleep();

      const moving =
        Math.abs(aim - pos) > 0.0004 || Math.abs(lensAim - lens) > 0.002;
      if (moving || drag || awake) frame = requestAnimationFrame(tick);
      else stamp = 0;
    };

    const start = () => {
      if (!frame) {
        stamp = 0;
        frame = requestAnimationFrame(tick);
      }
    };

    /* ---------------- veille ----------------
       Réveil rapide, endormissement lent : une barre de défilement doit
       arriver au moment du geste et repartir sans qu'on la voie partir. */

    const wake = () => {
      if (!live || max <= DEAD) return;
      act = performance.now();
      if (!awake) {
        awake = true;
        host.dataset.awake = "true";
      }
      start();
    };

    const sleep = () => {
      if (!awake) return;
      awake = false;
      first = false;
      host.dataset.awake = "false";
    };

    /* ---------------- saisie ---------------- */

    const at = (y: number) => clamp01((y - top) / height);

    /* Un centre de curseur, normalisé sur la règle, vers une position de
       contenu. La course utile n'est pas la hauteur de la règle mais
       celle moins le curseur : sans ça, le haut et le bas seraient
       inatteignables. */
    const seek = (center: number) => {
      const travel = Math.max(1e-4, 1 - 2 * half);
      return clamp01((center - half) / travel) * max;
    };

    const onDown = (e: PointerEvent) => {
      if (!live || max <= DEAD || e.button !== 0 || height <= 0) return;
      e.preventDefault();
      const t = at(e.clientY);
      /* Saisir le curseur le prend où il est, et le 1:1 commence tout de
         suite : le contenu est déjà là où on le tient. Cliquer ailleurs
         est une intention de lecture, pas une téléportation : on y va en
         glissant, et deux écrans sans transition ne se lisent plus comme
         un même document. */
      const inside = Math.abs(t - pos) <= half;
      anchor = e.clientY;
      hold = inside ? t - pos : 0;
      armed = true;
      drag = inside;
      lensAim = 1;
      root.classList.add("rle-drag");
      try {
        grip.setPointerCapture(e.pointerId);
      } catch {
        /* capture refusée : le suivi passe par les écouteurs de fenêtre */
      }
      jump(seek(t - hold), !inside);
      wake();
    };

    const onDrag = (e: PointerEvent) => {
      if (!armed) return;
      if (!drag) {
        /* Trois pixels : au-dessous, la main qui appuie tremble, elle ne
           glisse pas. Requalifier un tremblement en glissement couperait
           la glissade qu'on vient de lancer. */
        if (Math.abs(e.clientY - anchor) < SLOP) return;
        drag = true;
      }
      e.preventDefault();
      jump(seek(clamp01(at(e.clientY) - hold)), false);
      act = performance.now();
      start();
    };

    const onUp = (e: PointerEvent) => {
      if (!armed) return;
      armed = false;
      drag = false;
      root.classList.remove("rle-drag");
      try {
        grip.releasePointerCapture(e.pointerId);
      } catch {
        /* rien à libérer */
      }
      wake();
    };

    /* ---------------- écoute ---------------- */

    const onScroll = () => {
      if (!live) return;
      read();
      wake();
    };

    const onPointer = (e: PointerEvent) => {
      if (!live) return;
      if (height > 0) {
        const dx =
          e.clientX < box.left
            ? box.left - e.clientX
            : e.clientX > box.right
              ? e.clientX - box.right
              : 0;
        const dy =
          e.clientY < top
            ? top - e.clientY
            : e.clientY > top + height
              ? e.clientY - (top + height)
              : 0;
        lensAim = drag ? 1 : 1 - ease(clamp01(Math.hypot(dx, dy) / PROX));
        lensY = clamp01((e.clientY - top) / height);
      }
      wake();
    };

    /* `pointerout` sans cible liée : le pointeur a quitté la fenêtre. On
       éteint la loupe plutôt que de la laisser allumée sur un bord que
       plus personne ne vise. `pointerleave` ne remonterait pas jusqu'ici. */
    const onLeave = (e: PointerEvent) => {
      if (e.relatedTarget) return;
      lensAim = 0;
      start();
    };
    const onBlur = () => {
      lensAim = 0;
      start();
    };

    const onKey = () => wake();

    let pending = 0;
    const resync = () => {
      cancelAnimationFrame(pending);
      pending = requestAnimationFrame(() => {
        measure();
        if (live) start();
      });
    };

    measure();
    /* Elle se montre une fois au chargement, un peu plus longtemps que
       d'habitude : une barre qu'on n'a jamais vue ne se cherche pas. */
    const boot = window.setTimeout(wake, BOOT);

    const passive = { passive: true } as const;
    source.addEventListener("scroll", onScroll, passive);
    window.addEventListener("pointermove", onPointer, passive);
    window.addEventListener("keydown", onKey, passive);
    document.addEventListener("pointerout", onLeave as EventListener, passive);
    window.addEventListener("blur", onBlur, passive);
    window.addEventListener("resize", resync, passive);
    window.addEventListener("orientationchange", resync, passive);
    grip.addEventListener("pointerdown", onDown);
    window.addEventListener("pointermove", onDrag);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);

    /* La hauteur du contenu bouge sans que la fenêtre bouge : images qui
       arrivent, police qui remplace la police de secours, panneau qui se
       déplie. Une boîte défilée, elle, ne change pas de taille quand son
       contenu grandit : c'est le contenu qu'il faut observer. */
    const ro = new ResizeObserver(resync);
    ro.observe(body);
    if (pane) for (const kid of Array.from(pane.children)) ro.observe(kid);

    return () => {
      window.clearTimeout(boot);
      cancelAnimationFrame(frame);
      cancelAnimationFrame(pending);
      ro.disconnect();
      source.removeEventListener("scroll", onScroll);
      window.removeEventListener("pointermove", onPointer);
      window.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerout", onLeave as EventListener);
      window.removeEventListener("blur", onBlur);
      window.removeEventListener("resize", resync);
      window.removeEventListener("orientationchange", resync);
      grip.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointermove", onDrag);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
      root.classList.remove("rle-drag");
    };
  }, [scroller, sections]);

  return (
    <div
      ref={rail}
      className={className ? `rle ${className}` : "rle"}
      data-page={scroller ? "false" : "true"}
      data-awake="false"
      data-scroll="false"
      aria-hidden="true"
    >
      <div ref={track} className="rle__gauge" />
      <div ref={hit} className="rle__hit" />
    </div>
  );
}

Ailleurs dans le Livre

Sedan, disponible pour de nouveaux projetsRetour · publié le 1er septembre 2026