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
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.
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;
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.
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);
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.
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);
}
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.
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.2accélère avec la fréquence de l'écran : la même règle est molle à 60 Hz et sèche à 144 Hz. La forme1 - e^(-dt/τ)donne le même mouvement partout, et c'est un seul appel àMath.exppar trame.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".autoveut dire « suis lescroll-behaviordu CSS », qui vaut souventsmooth. Pendant un glissement, ça transforme le suivi au pixel en caoutchouc. Le mot qui force l'instantané estinstant.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.
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
ResizeObserverposé 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, observerdocumentElementsuffit. - Elle ne s'annonce pas comme une barre.
aria-hiddenest 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.@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>
);
}
/* Règle de défilement · src/components/book/kit/Rail.tsx */
/* Mode boîte : posée dans le conteneur défilé, qui doit donc porter
`position: relative`. Le composant n'écrit que du `scaleX` et de
l'`opacity` : tout le reste est ici, en dur. */
.rle {
position: absolute;
z-index: 5;
top: 0;
bottom: 0;
right: 8px;
margin-block: auto;
width: 26px;
height: clamp(7rem, 64%, 30rem);
color: var(--fg, #1c1c1c);
pointer-events: none;
}
/* Mode page : fixée au bord de la fenêtre. Elle n'existe qu'au-delà de
1120 px, avec un pointeur fin et de la hauteur, et c'est exactement là
qu'on peut effacer la barre du système. En dessous, au doigt ou sans
JS, le système garde la main : on ne retire jamais un moyen de défiler
sans en poser un autre qui marche. */
.rle[data-page="true"] {
position: fixed;
right: max(1.25rem, env(safe-area-inset-right));
height: clamp(14rem, 42vh, 30rem);
display: none;
}
@media (min-width: 70rem) and (min-height: 34rem) and (hover: hover) and
(pointer: fine) {
.rle[data-page="true"] {
display: block;
}
}
/* Réveil rapide, endormissement lent : une barre de défilement doit
arriver au moment du geste et repartir sans qu'on la voie partir. */
.rle__gauge {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 620ms cubic-bezier(0.33, 0, 0.15, 1);
}
.rle[data-awake="true"] .rle__gauge {
opacity: 1;
transition-duration: 190ms;
}
/* Longueur pleine posée dans le DOM, jamais animée : seul `scaleX` bouge,
depuis le bord droit. Les valeurs ci-dessous sont l'état au repos,
celles que le composant reprend à la première trame. */
.rle__tick {
position: absolute;
right: 0;
width: 26px;
height: 1px;
background: currentColor;
opacity: 0.15;
transform: scaleX(0.26);
transform-origin: 100% 50%;
}
/* Une graduation posée sur un début de section part de plus loin et pèse
un peu plus lourd : la règle donne le découpage du contenu en même
temps que la position. Le plancher seul change : le renflement du
curseur passe par-dessus, identique partout. */
.rle__tick[data-mark] {
opacity: 0.3;
transform: scaleX(0.42);
}
/* Zone de saisie plus large que le dessin : viser six pixels de large à
la souris est un test d'adresse, pas une interface. */
.rle__hit {
position: absolute;
inset: -12px -16px;
pointer-events: auto;
cursor: grab;
touch-action: none;
}
.rle[data-scroll="false"] .rle__hit {
pointer-events: none;
}
html.rle-drag,
html.rle-drag * {
cursor: grabbing !important;
user-select: none !important;
}
/* Optionnel, à poser sur le conteneur défilé : il n'y a pas deux barres à
lire. Le composant ne le fait pas de lui-même : masquer la barre du
système est une décision de page, jamais de composant. */
.rle-mute {
scrollbar-width: none;
}
.rle-mute::-webkit-scrollbar {
width: 0;
height: 0;
}
/* La règle continue de suivre la position : c'est une mesure, pas un
ornement. Mais sans fondu au réveil. */
@media (prefers-reduced-motion: reduce) {
.rle__gauge,
.rle[data-awake="true"] .rle__gauge {
transition-duration: 0.01ms;
}
}
@media (forced-colors: active) {
.rle__tick {
background: CanvasText;
}
}
@media print {
.rle {
display: none !important;
}
}