Accueil

Le Livre

Un atelier ouvert : les composants d'interface que j'écris pour mes projets, isolés, documentés et prêts à être repris. Pas un paquet à installer : des fichiers à copier, et c'est à vous.

Les vignettes ci-dessous ne sont pas des captures d'écran. Ce sont les composants eux-mêmes, montés dans la page : tapez dedans, survolez-les, tabulez à travers. Le code affiché est lu sur le disque au moment du build. Il ne peut pas avoir dérivé de ce que vous voyez.

Sept planches, trois chapitres : Retour, Objets et Surface. Chacune donne le pourquoi avant le comment, et documente les pièges au même titre que les solutions.

Aucune dépendance, aucun réglage de build. Les couleurs passent par des variables avec repli : chaque composant adopte vos jetons s'ils existent, et reste correct s'ils n'existent pas. Tapez ⌘K pour parcourir le site.

Retour

Ce que l'interface répond. Court, calme, et jamais au milieu du chemin.

Traitement
Réglages
Motif
Chrono
page.tsx
<Attente
  mot="Traitement"
  motif="chasse"
/>

AttenteLoading state

Neuf pixels, un mot et un chronomètre, et pas une seule trame demandée.

"use client";

import { useEffect, useRef, useState } from "react";

/* ------------------------------------------------------------------
   Attente · une grille de neuf pixels, un mot, et un chronomètre.

   Le sablier, la roue qui tourne, la barre qui n'avance pas : trois
   façons de dire « patientez » et aucune de dire combien de temps. Ce
   composant en dit trois choses, et c'est ce qui le distingue :

     CE QUI TRAVAILLE  : le mot. Pas « Chargement », mais le verbe de
                          ce qui se passe. Un mot qui change pendant
                          l'attente dit qu'on avance ; un mot fixe dit
                          seulement qu'on n'a pas planté.
     QUE ÇA VIT        : la grille. Une onde traverse trois colonnes,
                          et son cycle est PLUS COURT que la traversée :
                          deux fronts sont toujours en vol, donc la
                          grille n'est jamais éteinte.
     DEPUIS QUAND      : le chronomètre. C'est la seule information
                          honnête qu'on puisse donner sans connaître la
                          fin, et c'est celle qu'on cherche des yeux au
                          bout de dix secondes.

   NEUF ÉLÉMENTS, ZÉRO TRAME. Les neuf cellules sont animées par une
   `@keyframes` CSS avec neuf retards différents : le compositeur s'en
   occupe, le fil principal n'est réveillé par rien. Le chronomètre est
   la seule chose qui tourne, dix fois par seconde, pas soixante, parce
   qu'un dixième de seconde ne change pas plus vite que ça.

   LE CHRONOMÈTRE N'EST PAS UN COMPTEUR. Il ne s'incrémente pas de cent
   millisecondes à chaque réveil : il relit l'horloge. Un intervalle en
   retard (onglet en arrière-plan, fil principal occupé, machine
   chargée) ferait dériver un compteur de plusieurs secondes sur une
   longue attente, et un chronomètre qui ment sur une attente longue
   ment exactement quand on le regarde.
------------------------------------------------------------------- */

export type Motif = "chasse" | "points" | "orbite";

export type AttenteProps = {
  /** Le verbe de ce qui travaille. Change-le pendant l'attente. */
  mot?: string;
  motif?: Motif;
  /** Affiche le temps écoulé depuis le montage. */
  chrono?: boolean;
  className?: string;
};

/* La chasse : un front en chevron qui traverse de gauche à droite. Le
   retard d'une cellule est sa colonne, plus son écart à la ligne du
   milieu, ce qui incline le front au lieu de le laisser droit. */
const CHASSE = Array.from({ length: 9 }, (_, i) =>
  ((i % 3) + Math.abs(Math.floor(i / 3) - 1)) * 90
);

/* L'orbite : une comète fait le tour du bord. Le centre ne s'allume
   jamais : il reste à l'état sourd, et c'est ce vide au milieu qui fait
   lire une rotation plutôt qu'un scintillement. */
const TOUR = [0, 1, 2, 5, 8, 7, 6, 3];
const ORBITE = Array.from({ length: 9 }, (_, i) => {
  const k = TOUR.indexOf(i);
  return k === -1 ? null : k * 110;
});

const MOTIFS: Record<Motif, { retards: (number | null)[]; duree: number; rond: boolean }> = {
  chasse: { retards: CHASSE, duree: 650, rond: false },
  points: { retards: CHASSE, duree: 650, rond: true },
  orbite: { retards: ORBITE, duree: 950, rond: false },
};

/** Le temps écoulé, relu sur l'horloge et jamais accumulé. */
function useEcoule(actif: boolean) {
  const depart = useRef(0);
  const [ms, setMs] = useState(0);
  useEffect(() => {
    if (!actif) return;
    depart.current = performance.now();
    setMs(0);
    const t = window.setInterval(
      () => setMs(performance.now() - depart.current),
      100
    );
    return () => window.clearInterval(t);
  }, [actif]);

  const s = ms / 1000;
  if (s < 60) return `${s.toFixed(1)} s`;
  return `${Math.floor(s / 60)} min ${(s % 60).toFixed(0).padStart(2, "0")} s`;
}

function Grille({ retards, duree, rond }: (typeof MOTIFS)[Motif]) {
  return (
    <span className="att__grille" aria-hidden="true" data-rond={rond || undefined}>
      {retards.map((r, i) => (
        <span
          key={i}
          className="att__px"
          data-morte={r === null || undefined}
          style={
            r === null
              ? undefined
              : ({ "--r": `${r}ms`, "--t": `${duree}ms` } as React.CSSProperties)
          }
        />
      ))}
    </span>
  );
}

export default function Attente({
  mot = "Traitement",
  motif = "chasse",
  chrono = true,
  className,
}: AttenteProps) {
  const ecoule = useEcoule(chrono);
  const m = MOTIFS[motif] ?? MOTIFS.chasse;

  return (
    /* `role="status"` et pas `aria-live="assertive"` : une attente
       s'annonce quand le lecteur d'écran a fini sa phrase, elle ne
       l'interrompt pas. Le chronomètre en est retiré : il changerait
       dix fois par seconde, et personne ne veut entendre ça. */
    <div className={className ? `att ${className}` : "att"} role="status">
      <Grille {...m} />
      <span className="att__mot">{mot}</span>
      {chrono && (
        <span className="att__chrono" aria-hidden="true">
          {ecoule}
        </span>
      )}
    </div>
  );
}

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.

Règle de défilementScrollbar

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

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

Objets

Des pièces figuratives, à mi-chemin du décor et de la commande. C'est là, et seulement là, qu'un ressort a sa place.

Réglages
page.tsx
<Sphere
  derive={5}
  style={{
    "--sph-r": "148px",
    "--sph-w": "60px",
    "--sph-fuite": "520px",
  }}
/>

SphèreImage sphere

Seize images sur une sphère qu'on lance à la main, et pas une ligne de WebGL.

"use client";

import { useEffect, useRef, useState } from "react";

/* ------------------------------------------------------------------
   Sphère · des images posées sur une sphère qu'on fait tourner.

   La référence est écrite en Three.js : une scène, une caméra, des
   plans texturés, un lancer de rayon pour savoir ce qu'on survole. Six
   cents kilo-octets pour vingt vignettes. Celle-ci est en CSS, et la
   raison n'est pas l'économie : c'est que la moitié du travail
   disparaît.

   DES IMAGES, PAS DES TEXTURES. Ce sont de vrais `<img>` : le
   navigateur les charge en différé, les décode hors du fil principal,
   les sert au bon format, et le survol comme le clic sont des
   événements ordinaires. Le lancer de rayon de la version WebGL
   n'existe plus : c'est le test de collision du navigateur, qui est
   plus juste que le nôtre et qui ne coûte rien.

   LE PANNEAU EST L'ASTUCE. Une image posée sur une sphère qui tourne
   présenterait sa tranche dès le quart de tour. Il faut donc la
   contre-tourner, exactement, à chaque trame. Écrire seize
   transformations par trame serait tenable ; en écrire UNE l'est
   davantage : la contre-rotation est une propriété personnalisée posée
   sur le monde, et elle est HÉRITÉE par les seize vignettes. Un seul
   accès au style par trame, quelle que soit la population.

   TROIS NIVEAUX, ET CHACUN SON MÉTIER. La place ne bouge jamais, la
   vignette se contre-tourne sans transition (sous peine de traîner
   d'une trame derrière le monde), et le cadre porte le survol, avec
   transition. Empiler les trois sur le même nœud, c'est faire
   transitionner la contre-rotation, et la sphère devient molle.

   LE FONDU AU LOIN n'est pas une coquetterie : sans lui, une sphère
   creuse se lit comme un nuage plat. La profondeur de chaque place est
   calculée à la main (un produit scalaire, vingt fois par trame) et
   passée en variable, que la feuille traduit en opacité.
------------------------------------------------------------------- */

export type Vignette = {
  src: string;
  /** La pleine résolution, chargée seulement à l'ouverture. */
  grand?: string;
  /** Vide pour une image décorative : le bouton prend alors son rang. */
  alt: string;
};

export type SphereProps = {
  images: Vignette[];
  /** Vitesse de la rotation au repos, en degrés par seconde. */
  derive?: number;
  /** Part de la boîte qu'occupe une image mise en avant. */
  avant?: number;
  className?: string;
  /**
   * Le reste passe par les propriétés personnalisées : `--sph-r` le
   * rayon, `--sph-w` la largeur d'une vignette, `--sph-fuite` la
   * distance du point de fuite.
   */
  style?: React.CSSProperties;
};

/* La spirale de Fibonacci, et pas vingt tirages au sort. Le hasard fait
   des grappes : sur seize points, il en colle trois dans un coin et
   laisse un trou en face, et la sphère se lit comme un tas. La spirale
   dorée répartit sans jamais aligner. */
function spirale(n: number) {
  const or = Math.PI * (3 - Math.sqrt(5));
  return Array.from({ length: n }, (_, i) => {
    const y = n === 1 ? 0 : 1 - (i / (n - 1)) * 2;
    const r = Math.sqrt(Math.max(0, 1 - y * y));
    const t = or * i;
    /* Le rayon respire d'un sixième. Sur une coque parfaite, les seize
       images sont à la même distance : elles se lisent comme un anneau
       vu de face, pas comme un volume. Un peu d'épaisseur, et l'œil
       trouve enfin des images DEVANT d'autres. Déterministe, pas
       tirée au sort : le serveur et le client doivent tomber d'accord. */
    const e = 1 + 0.17 * Math.sin(i * 2.3999632);
    return { x: Math.cos(t) * r * e, y: y * e, z: Math.sin(t) * r * e };
  });
}

const rad = Math.PI / 180;

export default function Sphere({
  images,
  derive = 5,
  avant = 0.72,
  className,
  style,
}: SphereProps) {
  const boite = useRef<HTMLDivElement>(null);
  /* La boucle est montée une fois : elle appelle la fermeture par
     référence, faute de quoi Échap fermerait avec la version du premier
     rendu, et sauterait le voyage de retour. */
  const fermerRef = useRef<() => void>(() => {});
  const monde = useRef<HTMLDivElement>(null);
  const vue = useRef<HTMLDivElement>(null);
  const [ouverte, setOuverte] = useState<number | null>(null);
  /* La fermeture est un ÉTAT, pas un démontage. L'image doit refaire le
     chemin inverse avant de disparaître : tant qu'elle vole, elle est
     encore là. */
  const [ferme, setFerme] = useState(false);
  const ouverteRef = useRef<number | null>(null);
  ouverteRef.current = ouverte;
  const minuteurF = useRef(0);
  useEffect(() => () => window.clearTimeout(minuteurF.current), []);

  const points = spirale(images.length);

  useEffect(() => {
    const el = boite.current;
    const mo = monde.current;
    if (!el || !mo) return;

    const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const places = [...mo.children] as HTMLElement[];

    let rx = -12;
    let ry = 0;
    let vx = 0;
    let vy = 0;
    let tire = false;
    let dernierX = 0;
    let dernierY = 0;
    let dx = 0;
    let dy = 0;
    let chemin = 0;
    let raf = 0;
    let tourne = false;
    let clavier = false;
    let approche = false;
    let avantT = 0;

    const peindre = () => {
      mo.style.transform = `rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg)`;
      /* La contre-rotation, écrite UNE fois et héritée par tout le
         monde. L'ordre est l'inverse exact de celui du monde. */
      mo.style.setProperty(
        "--sph-inv",
        `rotateY(${(-ry).toFixed(2)}deg) rotateX(${(-rx).toFixed(2)}deg)`
      );

      /* La profondeur, à la main. Le z d'un point après `rotateX(a)
         rotateY(b)` : un produit scalaire, pas une matrice. */
      const sa = Math.sin(rx * rad);
      const ca = Math.cos(rx * rad);
      const sb = Math.sin(ry * rad);
      const cb = Math.cos(ry * rad);
      for (let i = 0; i < places.length; i++) {
        const p = points[i];
        if (!p) continue;
        const z = sa * p.y + ca * (p.z * cb - p.x * sb);
        places[i].style.setProperty("--d", ((z + 1) / 2).toFixed(3));
      }
    };

    const trame = (t: number) => {
      if (!tourne) return;
      const dt = avantT ? Math.min(0.05, (t - avantT) / 1000) : 1 / 60;
      avantT = t;

      if (!tire) {
        if (vx || vy) {
          ry += vy;
          rx -= vx;
          const k = Math.pow(0.94, dt * 60);
          vx *= k;
          vy *= k;
          if (Math.abs(vx) < 0.004) vx = 0;
          if (Math.abs(vy) < 0.004) vy = 0;
        }
        /* Le repos n'est pas l'immobilité : une sphère arrêtée ne dit
           pas qu'elle tourne. Elle ne dérive que lorsque personne ne la
           tient, qu'aucune image n'est ouverte, que le clavier n'est pas
           dedans (une cible qu'on vient d'atteindre à la tabulation n'a
           pas le droit de s'en aller toute seule), et que la main n'est
           pas au-dessus. Une sphère qui continue de tourner sous le
           curseur DÉROBE ce qu'on vise : on approche d'une image, elle
           glisse, on clique à côté. Elle se pose à l'approche. */
        if (ouverteRef.current === null && !clavier && !approche)
          ry += derive * dt;
      }
      rx = Math.max(-72, Math.min(72, rx));
      peindre();
      raf = requestAnimationFrame(trame);
    };

    const partir = () => {
      if (tourne || calme) return;
      tourne = true;
      avantT = 0;
      raf = requestAnimationFrame(trame);
    };
    const arreter = () => {
      tourne = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    /* ---------- La main ---------- */
    let capture = 0;
    const surAppui = (e: PointerEvent) => {
      if (e.button !== 0 && e.pointerType === "mouse") return;
      tire = true;
      vx = vy = dx = dy = chemin = 0;
      capture = 0;
      dernierX = e.clientX;
      dernierY = e.clientY;
      el.dataset.tire = "true";
    };
    const surMouvement = (e: PointerEvent) => {
      if (!tire) return;
      dx = e.clientX - dernierX;
      dy = e.clientY - dernierY;
      dernierX = e.clientX;
      dernierY = e.clientY;
      chemin += Math.hypot(dx, dy);
      /* La capture du pointeur n'est demandée QU'UNE FOIS LE GLISSEMENT
         AVÉRÉ, et c'est la correction la moins évidente de la planche.
         Capturer dès l'appui règle le vrai problème (poursuivre le
         geste quand la main sort de la boîte) et en crée un pire : tant
         que la capture est en place, le navigateur redirige aussi le
         `click` vers l'élément capturant. Le clic n'atteint donc JAMAIS
         la vignette, et aucune image ne s'ouvre. Trois pixels de retard,
         et les deux gestes cohabitent. */
      if (!capture && chemin > 3) {
        capture = e.pointerId;
        el.setPointerCapture?.(e.pointerId);
      }
      ry += dx * 0.28;
      rx -= dy * 0.28;
      rx = Math.max(-72, Math.min(72, rx));
      if (calme) peindre();
    };
    const surRelache = () => {
      if (!tire) return;
      tire = false;
      if (capture) {
        el.releasePointerCapture?.(capture);
        capture = 0;
      }
      delete el.dataset.tire;
      /* L'élan reprend le DERNIER déplacement, pas une moyenne : c'est
         ce qui donne l'impression d'avoir lâché quelque chose plutôt
         que d'avoir relâché un ressort. */
      vy = dx * 0.22;
      vx = -dy * 0.22;
    };
    /* La main s'approche, la sphère se pose. Souris seulement : sur un
       écran tactile il n'y a pas de survol, et l'appui suffit déjà à
       l'arrêter. */
    const surEntree2 = (e: PointerEvent) => {
      if (e.pointerType === "mouse") approche = true;
    };
    const surSortie2 = () => {
      approche = false;
    };
    el.addEventListener("pointerenter", surEntree2);
    el.addEventListener("pointerleave", surSortie2);
    el.addEventListener("pointerdown", surAppui);
    el.addEventListener("pointermove", surMouvement);
    el.addEventListener("pointerup", surRelache);
    el.addEventListener("pointercancel", surRelache);

    /* ---------- Le clic, distingué du glissement ----------

       Ce n'est pas la distance entre l'appui et le relâchement qui
       compte, c'est le chemin PARCOURU pendant qu'on tenait la sphère :
       un aller-retour revient à son point de départ, et il a pourtant
       fait tourner le monde d'un demi-tour. On accumule donc, et on
       annule le clic au-delà de six pixels.

       Le compteur part de zéro et n'est incrémenté que sous la main. Si
       l'appui n'est jamais arrivé (un pointeur exotique, un événement
       synthétique), il vaut zéro et le clic passe. C'est le bon sens de
       la panne : un garde-fou qui doute laisse passer, il ne bloque pas. */
    const surFin = (e: MouseEvent) => {
      if (chemin > 6) {
        e.preventDefault();
        e.stopPropagation();
      }
    };
    el.addEventListener("click", surFin, true);

    const surTouche = (e: KeyboardEvent) => {
      if (e.key === "Escape" && ouverteRef.current !== null) fermerRef.current();
    };
    window.addEventListener("keydown", surTouche);

    /* Le clavier entre, la sphère s'arrête. Sans ça, on tabule jusqu'à
       une vignette et elle part en tournant : l'anneau de focus s'en va
       avec elle, et l'on ne sait plus où l'on est. */
    const surEntree = () => { clavier = true; };
    const surSortie = () => { clavier = false; };
    el.addEventListener("focusin", surEntree);
    el.addEventListener("focusout", surSortie);

    let visible = false;
    let cachee = false;
    const accorder = () => (visible && !cachee ? partir() : arreter());
    const io = new IntersectionObserver(
      (es) => { visible = es[0]?.isIntersecting ?? false; accorder(); },
      { threshold: 0.1 }
    );
    io.observe(el);
    const surVisibilite = () => { cachee = document.hidden; accorder(); };
    document.addEventListener("visibilitychange", surVisibilite);

    peindre();
    accorder();

    return () => {
      arreter();
      io.disconnect();
      document.removeEventListener("visibilitychange", surVisibilite);
      window.removeEventListener("keydown", surTouche);
      el.removeEventListener("focusin", surEntree);
      el.removeEventListener("focusout", surSortie);
      el.removeEventListener("pointerenter", surEntree2);
      el.removeEventListener("pointerleave", surSortie2);
      el.removeEventListener("pointerdown", surAppui);
      el.removeEventListener("pointermove", surMouvement);
      el.removeEventListener("pointerup", surRelache);
      el.removeEventListener("pointercancel", surRelache);
      el.removeEventListener("click", surFin, true);
    };
  }, [derive, images.length]);

  /* ---------- L'ouverture, en FLIP ----------

     L'image ne réapparaît pas au centre : elle y VA. On relève la place
     qu'occupe la vignette à l'écran, on laisse le navigateur poser la
     grande image à sa place finale, puis on lui applique l'écart entre
     les deux et on le retire dans une transition. Deux mesures et une
     soustraction remplacent une interpolation de position dans une
     scène 3D, et le trajet est exact, parce qu'il part d'une mesure et
     non d'un modèle. */
  /** L'écart entre deux rectangles, en transformation. */
  const ecart = (de: DOMRect, a: DOMRect) =>
    `translate(${de.left - a.left}px, ${de.top - a.top}px) scale(${
      de.width / a.width
    })`;

  const ouvrir = (i: number, source: HTMLElement) => {
    const de = source.getBoundingClientRect();
    window.clearTimeout(minuteurF.current);
    setFerme(false);
    setOuverte(i);
    requestAnimationFrame(() => {
      const cible = vue.current?.querySelector("img");
      if (!cible) return;
      const a = cible.getBoundingClientRect();
      if (!a.width || !de.width) return;
      cible.style.transition = "none";
      cible.style.transformOrigin = "top left";
      cible.style.transform = ecart(de, a);
      void cible.offsetWidth;
      cible.style.transition = "";
      cible.style.transform = "";
    });
  };

  /* Le retour, et c'est la moitié qui manquait. Une image qui arrive en
     volant et repart en disparaissant laisse le geste inachevé : on ne
     sait plus d'où elle venait, donc où l'on en était. Elle refait le
     chemin, à l'envers, vers la vignette qu'elle a quittée, laquelle
     n'a pas bougé, puisque la sphère s'arrête tant qu'une image est
     ouverte. */
  const fermer = () => {
    const i = ouverteRef.current;
    const cible = vue.current?.querySelector("img");
    const source = boite.current?.querySelectorAll<HTMLElement>(".sph__place")[
      i ?? -1
    ];
    if (i === null || !cible || !source) {
      setOuverte(null);
      return;
    }
    const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    setFerme(true);
    if (calme) {
      setOuverte(null);
      setFerme(false);
      return;
    }
    const a = cible.getBoundingClientRect();
    const de = source.getBoundingClientRect();
    if (a.width && de.width) {
      cible.style.transformOrigin = "top left";
      cible.style.transform = ecart(de, a);
    }
    /* On attend la fin du voyage pour démonter, et pas une milliseconde
       de plus : un `transitionend` serait plus juste, mais il ne se
       déclenche pas si la transformation n'a rien à interpoler (une
       vignette exactement à la taille de la grande image, un moteur qui
       coupe court), et la carte resterait alors avec son voile pour
       toujours. Un minuteur ne rate jamais. */
    minuteurF.current = window.setTimeout(() => {
      setOuverte(null);
      setFerme(false);
    }, 460);
  };

  fermerRef.current = fermer;
  const dessus = ouverte !== null ? images[ouverte] : null;

  return (
    <div
      ref={boite}
      className={className ? `sph ${className}` : "sph"}
      data-vue={dessus ? "true" : undefined}
      style={style}
    >
      <div className="sph__monde" ref={monde}>
        {images.map((im, i) => {
          const p = points[i];
          return (
            <div
              key={im.src}
              className="sph__pos"
              style={
                {
                  "--x": p.x.toFixed(4),
                  "--y": p.y.toFixed(4),
                  "--z": p.z.toFixed(4),
                  "--d": ((p.z + 1) / 2).toFixed(3),
                } as React.CSSProperties
              }
            >
              <button
                type="button"
                className="sph__place"
                onClick={(e) => ouvrir(i, e.currentTarget)}
                tabIndex={dessus ? -1 : 0}
                /* Le NOM est sur le bouton, pas sur l'image. Une image
                   décorative garde un `alt` vide : la décrire deux fois
                   ferait annoncer « image, bouton, image ». Sans texte
                   fourni, le rang sert de nom : il dit au moins où l'on
                   est dans la série. */
                aria-label={
                  im.alt || `Agrandir l'image ${i + 1} sur ${images.length}`
                }
              >
                <span className="sph__cadre">
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img src={im.src} alt={im.alt} loading="lazy" draggable={false} />
                </span>
              </button>
            </div>
          );
        })}
      </div>

      {dessus && (
        <div className="sph__vue" ref={vue} data-ferme={ferme || undefined}>
          {/* Le voile. Il ne se contente pas d'assombrir la sphère : il
              la RECOUVRE. Une sphère seulement atténuée derrière une
              photographie reste une bouillie d'images, et l'œil continue
              d'y chercher des formes au lieu de regarder celle qu'on
              vient d'ouvrir. */}
          <button
            type="button"
            className="sph__voile"
            onClick={fermer}
            tabIndex={-1}
            aria-hidden="true"
          />
          <button
            type="button"
            className="sph__fermer"
            onClick={fermer}
            autoFocus
          >
            <span className="sr">Fermer l&apos;image</span>
            <span aria-hidden="true">Fermer</span>
          </button>
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={dessus.grand ?? dessus.src}
            alt={dessus.alt}
            draggable={false}
            style={{ "--sph-avant": avant } as React.CSSProperties}
          />
        </div>
      )}
    </div>
  );
}
Réglages
Couleur

Survolez : les fiches se soulèvent. Cliquez : le dossier s’ouvre.

DossierFolder

Trois fiches dans une chemise qui s'entrouvre au survol et s'ouvre au clic.

"use client";

/* ------------------------------------------------------------------
   Dossier.

   Un objet figuratif : trois fiches glissées dans une chemise qui
   s'entrouvre au survol et s'ouvre au clic.

   Le ressort est ici légitime, et c'est la seule raison pour laquelle il
   est là. Une chemise en carton a une masse : quand on la soulève, elle
   dépasse un peu et revient. Sur un bouton ou un menu, le même ressort
   ferait jouet : il raconterait une inertie qui n'existe pas. La règle
   n'est pas « pas de ressort », elle est « un ressort seulement quand
   l'objet en aurait un dans la vraie vie ».

   Rendu sans bibliothèque d'animation : l'intégrateur ci-dessous est un
   oscillateur amorti standard, aux mêmes constantes que la source
   (raideur 120, amortissement 13, masse 1). Il se met à jour hors de
   React : aucun rendu déclenché pendant le mouvement, une seule boucle
   pour les dix valeurs animées.
------------------------------------------------------------------- */

import { useCallback, useEffect, useRef, useState } from "react";

/* ---------- Thèmes ---------- */
const themes = {
  black: {
    backFill: "black",
    backInsetShadow: "inset 0 0 6px 2px rgba(255,255,255,0.37)",
    flapFill: "#292929",
    flapFillOpacity: 0.25,
    flapStroke: "#979797",
    flapInsetColor: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0",
    cardFill: "#F1F1F1",
    cardStroke: "#E0E0E0",
    cardLineFill: "#D4D4D4",
    cardInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0",
  },
  white: {
    backFill: "#ffffff",
    backInsetShadow: "inset 0 0 6px 2px rgba(178,178,178,0.25)",
    flapFill: "#f5f5f5",
    flapFillOpacity: 0.85,
    flapStroke: "#d4d4d4",
    flapInsetColor: "0 0 0 0 0.6 0 0 0 0 0.6 0 0 0 0 0.6 0 0 0 0.15 0",
    cardFill: "#262626",
    cardStroke: "#404040",
    cardLineFill: "#737373",
    cardInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0",
  },
  blue: {
    backFill: "#50B1FD",
    backInsetShadow: "inset 0 0 6px 2px rgba(255,255,255,0.35)",
    flapFill: "#3a9ae8",
    flapFillOpacity: 0.45,
    flapStroke: "#7ec8ff",
    flapInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.12 0",
    cardFill: "#F1F1F1",
    cardStroke: "#E0E0E0",
    cardLineFill: "#D4D4D4",
    cardInsetColor: "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0",
  },
} as const;

const sizeScales = { sm: 0.65, md: 1, lg: 1.35 } as const;

export type FolderProps = Omit<React.ComponentProps<"div">, "color"> & {
  color?: keyof typeof themes;
  size?: keyof typeof sizeScales;
  /** Libellé du bouton pour les lecteurs d'écran. */
  label?: string;
};

const BASE_WIDTH = 321;
const BASE_HEIGHT = 270;

const FLAP_PATH =
  "M0 25C0 11.1929 11.1929 0 25 0H136.084C143.044 0 149.689 2.90139 154.42 8.00608L178.08 33.5343C182.811 38.639 189.456 41.5404 196.416 41.5404H296C309.807 41.5404 321 52.7333 321 66.5404V216C321 229.807 309.807 241 296 241H25C11.1929 241 0 229.807 0 216V25Z";

/* ---------- Poses. Reprises telles quelles de la source. ---------- */
type Pose = { x: number; y: number; r: number };
const CARDS: Record<"rest" | "hover" | "open", [Pose, Pose, Pose]> = {
  rest: [
    { x: 40, y: -10, r: 10 },
    { x: 3, y: -20, r: 2 },
    { x: -40, y: -22, r: -5 },
  ],
  hover: [
    { x: 40, y: -30, r: 14 },
    { x: 3, y: -35, r: -1 },
    { x: -40, y: -44, r: -9 },
  ],
  open: [
    { x: 70, y: -160, r: 18 },
    { x: 0, y: -180, r: -3 },
    { x: -65, y: -170, r: -14 },
  ],
};
const FLAP: Record<"rest" | "hover" | "open", number> = {
  rest: -15,
  hover: -45,
  open: -55,
};
/* Décalages d'entrée, en millisecondes. La fiche du dessus part la
   dernière : c'est ce qui fait lire un éventail plutôt qu'un bloc. */
const DELAYS: Record<"rest" | "hover" | "open", [number, number, number]> = {
  rest: [0, 0, 0],
  hover: [120, 60, 0],
  open: [100, 50, 0],
};

/* ---------- Oscillateur amorti ---------- */
const STIFFNESS = 120;
const DAMPING = 13;
const MASS = 1;
const SUBSTEP = 1 / 240; /* pas fixe : le mouvement est identique à 60 et 120 Hz */
const REST_X = 0.01;
const REST_V = 0.02;

type Spring = { cur: number; vel: number; to: number; next: number | null; at: number };
const makeSpring = (v: number): Spring => ({ cur: v, vel: 0, to: v, next: null, at: 0 });

function aim(s: Spring, to: number, delay: number, now: number, snap: boolean) {
  if (snap) {
    s.to = to;
    s.cur = to;
    s.vel = 0;
    s.next = null;
    return;
  }
  if (delay <= 0) {
    s.to = to;
    s.next = null;
    return;
  }
  s.next = to;
  s.at = now + delay;
}

/** Avance un ressort de `dt` secondes. Rend `true` tant qu'il bouge. */
function step(s: Spring, dt: number, now: number): boolean {
  if (s.next !== null && now >= s.at) {
    s.to = s.next;
    s.next = null;
  }
  let t = dt;
  while (t > 0) {
    const h = t < SUBSTEP ? t : SUBSTEP;
    const a = (-STIFFNESS * (s.cur - s.to) - DAMPING * s.vel) / MASS;
    s.vel += a * h;
    s.cur += s.vel * h;
    t -= h;
  }
  return (
    s.next !== null || Math.abs(s.cur - s.to) > REST_X || Math.abs(s.vel) > REST_V
  );
}

export default function Folder({
  color = "black",
  size = "md",
  label = "Ouvrir le dossier",
  className,
  ...rest
}: FolderProps) {
  const theme = themes[color] ?? themes.black;
  const scale = sizeScales[size] ?? 1;

  const [open, setOpen] = useState(false);
  const [lit, setLit] = useState(false);

  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  const flapRef = useRef<HTMLDivElement>(null);
  const raf = useRef<number | null>(null);
  const last = useRef(0);

  /* Dix ressorts : trois par fiche, plus le rabat. Créés une seule fois. */
  const springs = useRef(
    CARDS.rest.map((p) => ({
      x: makeSpring(p.x),
      y: makeSpring(p.y),
      r: makeSpring(p.r),
    }))
  );
  const flapSpring = useRef(makeSpring(FLAP.rest));

  const paint = useCallback(() => {
    springs.current.forEach((s, i) => {
      const el = cardRefs.current[i];
      if (!el) return;
      el.style.transform = `translate(-50%, -50%) translate(${s.x.cur.toFixed(
        2
      )}px, ${s.y.cur.toFixed(2)}px) rotate(${s.r.cur.toFixed(2)}deg)`;
    });
    const flap = flapRef.current;
    if (flap) {
      flap.style.transform = `translate(-50%, -50%) rotateX(${flapSpring.current.cur.toFixed(
        2
      )}deg)`;
    }
  }, []);

  const loop = useCallback(
    (now: number) => {
      /* Plafond : un onglet remis au premier plan renvoie un écart énorme,
         et sans plafond le ressort explose au lieu de reprendre. */
      const dt = last.current ? Math.min((now - last.current) / 1000, 0.064) : SUBSTEP;
      last.current = now;

      let moving = false;
      for (const s of springs.current) {
        moving = step(s.x, dt, now) || moving;
        moving = step(s.y, dt, now) || moving;
        moving = step(s.r, dt, now) || moving;
      }
      moving = step(flapSpring.current, dt, now) || moving;

      paint();

      if (moving) {
        raf.current = requestAnimationFrame(loop);
      } else {
        raf.current = null;
        last.current = 0;
      }
    },
    [paint]
  );

  const run = useCallback(() => {
    if (raf.current === null) {
      last.current = 0;
      raf.current = requestAnimationFrame(loop);
    }
  }, [loop]);

  /* Une pose = un état. On vise, la boucle fait le reste. */
  useEffect(() => {
    const mode = open ? "open" : lit ? "hover" : "rest";
    const snap =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const now = typeof performance !== "undefined" ? performance.now() : 0;

    CARDS[mode].forEach((pose, i) => {
      const s = springs.current[i];
      const d = DELAYS[mode][i];
      aim(s.x, pose.x, d, now, snap);
      aim(s.y, pose.y, d, now, snap);
      aim(s.r, pose.r, d, now, snap);
    });
    aim(flapSpring.current, FLAP[mode], 0, now, snap);

    if (snap) paint();
    else run();
  }, [open, lit, paint, run]);

  useEffect(() => {
    paint();
    return () => {
      if (raf.current !== null) cancelAnimationFrame(raf.current);
    };
  }, [paint]);

  return (
    <div
      data-slot="folder"
      className={["fld", className].filter(Boolean).join(" ")}
      {...rest}
    >
      {/* Un bouton, pas une div : sans ça le dossier ne s'ouvre qu'à la
          souris. Le focus vaut survol, comme partout sur le site. */}
      <button
        type="button"
        className="fld__box"
        aria-expanded={open}
        aria-label={label}
        style={
          {
            "--fld-w": `${BASE_WIDTH * scale}px`,
            "--fld-h": `${BASE_HEIGHT * scale}px`,
            "--fld-scale": scale,
            "--fld-persp": `${800 * scale}px`,
          } as React.CSSProperties
        }
        onPointerEnter={(e) => {
          if (e.pointerType === "touch") return;
          setLit(true);
        }}
        onPointerLeave={() => {
          setLit(false);
          setOpen(false);
        }}
        onFocus={() => setLit(true)}
        onBlur={() => {
          setLit(false);
          setOpen(false);
        }}
        onClick={() => setOpen((o) => !o)}
      >
        <span className="fld__scene">
          <span
            className="fld__back"
            style={{
              backgroundColor: theme.backFill,
              boxShadow: theme.backInsetShadow,
            }}
          />

          {[0, 1, 2].map((i) => (
            <div
              key={i}
              className="fld__card"
              ref={(el) => {
                cardRefs.current[i] = el;
              }}
            >
              <Card id={i + 1} theme={theme} />
            </div>
          ))}

          <div className="fld__flap" ref={flapRef}>
            <span
              className="fld__glass"
              style={{
                clipPath: `path('${FLAP_PATH}')`,
                WebkitClipPath: `path('${FLAP_PATH}')`,
              }}
            />
            <svg
              className="fld__flapart"
              width="321"
              height="241"
              viewBox="0 0 321 241"
              fill="none"
              xmlns="http://www.w3.org/2000/svg"
              aria-hidden="true"
            >
              <g filter="url(#fld-flap-inset)">
                <path
                  d={FLAP_PATH}
                  fill={theme.flapFill}
                  fillOpacity={theme.flapFillOpacity}
                />
                <path
                  d="M25 0.5H136.084C142.905 0.5 149.417 3.3431 154.054 8.3457L177.713 33.874C182.539 39.0808 189.317 42.04 196.416 42.04H296C309.531 42.04 320.5 53.0092 320.5 66.54V216C320.5 229.531 309.531 240.5 296 240.5H25C11.469 240.5 0.5 229.531 0.5 216V25C0.5 11.469 11.469 0.5 25 0.5Z"
                  stroke={theme.flapStroke}
                />
              </g>
              <defs>
                <filter
                  id="fld-flap-inset"
                  x="-25.4"
                  y="-25.4"
                  width="371.8"
                  height="291.8"
                  filterUnits="userSpaceOnUse"
                  colorInterpolationFilters="sRGB"
                >
                  <feFlood floodOpacity="0" result="BackgroundImageFix" />
                  <feBlend
                    mode="normal"
                    in="SourceGraphic"
                    in2="BackgroundImageFix"
                    result="shape"
                  />
                  <feColorMatrix
                    in="SourceAlpha"
                    type="matrix"
                    values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
                    result="hardAlpha"
                  />
                  <feOffset />
                  <feGaussianBlur stdDeviation="2.65" />
                  <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
                  <feColorMatrix type="matrix" values={theme.flapInsetColor} />
                  <feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
                </filter>
              </defs>
            </svg>
          </div>
        </span>
      </button>
    </div>
  );
}

export type { FolderProps as FolderComponentProps };

/* ---------- La fiche ---------- */
type Theme = (typeof themes)[keyof typeof themes];

/* Les dix-huit lignes de texte sont régulières : pas de 14,1184 px, deux
   colonnes. On les engendre plutôt que de recopier dix-huit rectangles :
   même géométrie, un cinquième du fichier. */
const ROWS = 9;
const ROW_STEP = 14.1184;
const COLS = [
  { x: 14.8253, y0: 60.9939, m: "1 -0.000409158 0.00201956 0.999998" },
  { x: 84.4303, y0: 60.9617, m: "1 -0.000461045 0.00179228 0.999998" },
];

function Card({ id, theme }: { id: number; theme: Theme }) {
  const filterId = `fld-card-inset-${id}`;
  return (
    <div data-slot="folder-card">
      <svg
        width="164"
        height="214"
        viewBox="0 0 164 214"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
        aria-hidden="true"
      >
        <g filter={`url(#${filterId})`}>
          <rect width="163.078" height="213.262" rx="20" fill={theme.cardFill} />
        </g>
        <rect
          x="0.5"
          y="0.5"
          width="162.078"
          height="212.262"
          rx="19.5"
          stroke={theme.cardStroke}
        />
        {/* Le titre */}
        <rect
          x="14.1193"
          y="31.2091"
          width="134.84"
          height="11.8892"
          rx="5.94459"
          fill={theme.cardLineFill}
        />
        {/* Le corps */}
        {COLS.flatMap((col, c) =>
          Array.from({ length: ROWS }, (_, r) => (
            <rect
              key={`${c}-${r}`}
              width="64.5183"
              height="5.88276"
              rx="2.94138"
              transform={`matrix(${col.m} ${col.x} ${(col.y0 + r * ROW_STEP).toFixed(4)})`}
              fill={theme.cardLineFill}
            />
          ))
        )}
        <defs>
          <filter
            id={filterId}
            x="0"
            y="0"
            width="166.078"
            height="218.262"
            filterUnits="userSpaceOnUse"
            colorInterpolationFilters="sRGB"
          >
            <feFlood floodOpacity="0" result="BackgroundImageFix" />
            <feBlend
              mode="normal"
              in="SourceGraphic"
              in2="BackgroundImageFix"
              result="shape"
            />
            <feColorMatrix
              in="SourceAlpha"
              type="matrix"
              values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
              result="hardAlpha"
            />
            <feMorphology
              radius="2"
              operator="erode"
              in="SourceAlpha"
              result={`erode_${id}`}
            />
            <feOffset dx="3" dy="5" />
            <feGaussianBlur stdDeviation="3.05" />
            <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
            <feColorMatrix type="matrix" values={theme.cardInsetColor} />
            <feBlend mode="normal" in2="shape" result={`inner_${id}`} />
          </filter>
        </defs>
      </svg>
    </div>
  );
}

Surface

Ce qui se pose par-dessus la page, et doit savoir s'en aller proprement.

Réglages
page.tsx
<Loupe
  force={1}
  rides={1}
  frange={0.016}
  attirance={0.72}
/>

LoupeGlass type

Un shader qui ne déplace pas des lettres, mais des coordonnées de lecture.

"use client";

import { useEffect, useRef } from "react";
import { A_TEXTE, Loupe as Verre, type Reglages } from "./loupe-gl";

/* ------------------------------------------------------------------
   Loupe · de la typographie derrière un verre.

   Le shader ne déplace pas des lettres : il déplace des coordonnées de
   lecture. Le texte est peint une fois sur un canevas 2D, et pour chaque
   pixel de l'écran on demande quel point de cette image lire ici. Voir
   `loupe-gl.ts` ; ce fichier-ci ne s'occupe que du branchement : la
   boucle, la main, les couleurs du thème et l'arrêt.

   UNE SEULE BOUCLE, ET ELLE NE TOURNE PAS POUR RIEN. Hors écran, onglet
   caché, mouvement réduit : elle s'arrête. Et elle ne redémarre pas au
   début, l'horloge de la scène reprend là où elle s'était arrêtée, si
   bien qu'un retour d'onglet ne relance pas une animation qu'on avait
   déjà vue à moitié.

   ET LA LIGNE RESTE DANS LE DOM. Sans WebGL, la carte ne serait qu'un
   rectangle de papier : la première ligne des trois scènes est donc
   écrite en clair, au milieu, et ne s'efface que lorsque la toile prend
   la main. C'est la règle de la maison (l'animation est l'agrément, le
   texte est le contrat), et elle s'applique même à un texte décoratif.

   LES COULEURS VIENNENT DU THÈME, pas du composant. Le canevas source
   reste en noir sur blanc (le shader n'y lit qu'une couverture d'encre),
   et l'encre et le papier ne sont appliqués qu'à la dernière ligne du
   fragment. Poser le composant sur une carte claire ou sombre ne demande
   donc rien d'autre que deux variables CSS.
------------------------------------------------------------------- */

export type LoupeProps = {
  /** Force de la lentille, en multiple du réglage de chaque scène. */
  force?: number;
  /** Amplitude des rides, en multiple du réglage de chaque scène. */
  rides?: number;
  /** Écart des trois canaux au bord du verre. Zéro : pas de frange. */
  frange?: number;
  /** Part du chemin que la lentille fait vers le pointeur. */
  attirance?: number;
  className?: string;
};

export default function LoupeCarte({
  force = 1,
  rides = 1,
  frange = 0.016,
  attirance = 0.72,
  className,
}: LoupeProps) {
  const boite = useRef<HTMLDivElement>(null);
  const toile = useRef<HTMLCanvasElement>(null);
  /* Les réglages voyagent par une référence : les faire glisser ne
     remonte ni le contexte ni la texture. */
  const reg = useRef<Reglages>({ force, rides, frange, attirance });
  reg.current = { force, rides, frange, attirance };

  useEffect(() => {
    const el = boite.current;
    const cv = toile.current;
    if (!el || !cv) return;

    const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const style = getComputedStyle(el);

    const verre = new Verre(cv);
    if (!verre.ok) return;
    el.dataset.gl = "true";
    verre.couleurs(
      style.getPropertyValue("--lou-encre").trim() || "#efeae9",
      style.getPropertyValue("--lou-papier").trim() || "#0b0001"
    );
    verre.setPolice(style.fontFamily || "sans-serif");
    verre.taille(el.clientWidth, el.clientHeight, dpr);

    let raf = 0;
    let tourne = false;
    let avant = 0;

    const trame = () => {
      if (!tourne) return;
      const t = performance.now();
      const dt = Math.min(0.1, Math.max(1 / 240, (t - avant) / 1000));
      avant = t;
      verre.reglages = reg.current;
      verre.trame(dt);
      raf = requestAnimationFrame(trame);
    };
    const partir = () => {
      if (tourne) return;
      tourne = true;
      avant = performance.now();
      raf = requestAnimationFrame(trame);
    };
    const arreter = () => {
      tourne = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    let vue = false;
    let cache = false;
    const accorder = () => {
      if (calme) return;
      if (vue && !cache) partir();
      else arreter();
    };

    const io = new IntersectionObserver(
      (e) => { vue = e[0]?.isIntersecting ?? false; accorder(); },
      { threshold: 0.2 }
    );
    io.observe(el);
    const surVisibilite = () => { cache = document.hidden; accorder(); };
    document.addEventListener("visibilitychange", surVisibilite);

    let minuteur = 0;
    const ro = new ResizeObserver(() => {
      window.clearTimeout(minuteur);
      minuteur = window.setTimeout(() => {
        verre.taille(el.clientWidth, el.clientHeight, dpr);
        if (calme || !tourne) {
          verre.reglages = reg.current;
          verre.fige();
        }
      }, 140);
    });
    ro.observe(el);

    /* La main, et la souris seule : un appui laisserait la lentille
       plantée là où le doigt a quitté l'écran, sans rien pour l'en faire
       partir. */
    const surLaMain = (e: PointerEvent) => {
      if (e.pointerType !== "mouse") return;
      const b = cv.getBoundingClientRect();
      if (!b.width || !b.height) return;
      verre.pointeur({
        x: (e.clientX - b.left) / b.width,
        y: (e.clientY - b.top) / b.height,
      });
    };
    const sortie = () => verre.pointeur(null);
    cv.addEventListener("pointermove", surLaMain);
    cv.addEventListener("pointerleave", sortie);
    window.addEventListener("blur", sortie);

    /* La police du site arrive après le premier rendu : composer avant
       donne une planche en police système, et le texte change de visage
       sous les yeux du lecteur. */
    if (document.fonts?.ready) {
      void document.fonts.ready.then(() => {
        verre.setPolice(getComputedStyle(el).fontFamily || "sans-serif");
        if (calme || !tourne) {
          verre.reglages = reg.current;
          verre.fige();
        }
      });
    }

    verre.reglages = reg.current;
    verre.fige();
    if (!calme) accorder();

    return () => {
      arreter();
      io.disconnect();
      ro.disconnect();
      window.clearTimeout(minuteur);
      document.removeEventListener("visibilitychange", surVisibilite);
      cv.removeEventListener("pointermove", surLaMain);
      cv.removeEventListener("pointerleave", sortie);
      window.removeEventListener("blur", sortie);
      verre.detruire();
      delete el.dataset.gl;
    };
  }, []);

  return (
    <div
      ref={boite}
      className={className ? `lou ${className}` : "lou"}
      role="img"
      aria-label="De la typographie derrière un verre : une ligne monte et se déroule jusqu'à devenir lisible, une bille de verre au contour tremblant échange deux mots, puis des mots seuls traversent une onde grossissante. Le bord du verre sépare les couleurs, et la lentille suit le pointeur."
    >
      {/* Le repli, et il n'est pas construit après coup : la ligne est là
          d'emblée, et la toile ne fait que passer devant. */}
      <p className="lou__clair" aria-hidden="true">
        {A_TEXTE}
      </p>
      <canvas ref={toile} className="lou__toile" />
    </div>
  );
}

Le texte ne se fond pas.Il se forme.

Puis il se défait,et recommence autrement.

Réglages
page.tsx
<Cendre
  flou={16}
  front={0.22}
  ressort={16}
  repos={320}
  relief={0.006}
/>

CendreShader text reveal

Du texte peint dans une texture, qu'un nuage découvre pixel par pixel.

"use client";

import { useEffect, useRef, useState } from "react";
import { Cendre as Toile, dessinerTexte } from "./cendre-gl";

/* ------------------------------------------------------------------
   Cendre · deux blocs de texte qui se forment dans un nuage.

   Le texte n'est pas animé : il est PEINT. Les deux blocs sont composés
   une fois sur un canevas 2D hors écran, téléversés en texture, et c'est
   un fragment shader qui décide, pour chaque pixel, s'il est déjà
   découvert et de combien il est encore flou. Voir `cendre-gl.ts` pour
   ce qui se passe dans le shader ; ce fichier-ci ne s'occupe que du
   TEMPS.

   Le temps est un RESSORT, pas une durée. Un `cubic-bezier` sait aller
   d'un point à un autre en un temps donné, et c'est très bien pour une
   transition qu'on déclenche. Ici la valeur est poursuivie en continu,
   et un ressort amorti donne ce qu'aucune courbe ne donne : une arrivée
   qui décélère d'elle-même, une sortie qui peut être plus raide que
   l'entrée sans changer de vocabulaire, et une reprise à mi-course qui
   ne saute pas. Le ressort de sortie est plus dur que celui d'entrée :
   le texte se forme lentement et s'efface vite, ce qui est le rythme
   naturel de la lecture.

   Une machine à quatre états, et c'est tout le pilotage :

     entrée  → la cible est 1, on attend d'être arrivé
     repos   → on tient quelques centaines de millisecondes
     sortie  → la cible est 0, on attend d'être vide
     échange → on tire la paire suivante, on change la graine, on repart

   LA GRAINE est ce qui empêche la boucle de se dénoncer. Sans elle, le
   nuage se dissout deux fois de la même façon, et la troisième fois on
   ne regarde plus.

   CE QUI NE TOURNE PAS : hors écran, onglet caché, mouvement réduit. Une
   boucle `requestAnimationFrame` qui tourne pour personne est la façon
   la plus discrète de vider une batterie.

   ET LE TEXTE RESTE DANS LE DOM. C'est la règle de la maison :
   l'animation est l'agrément, le texte est le contrat. Sans WebGL, sans
   JavaScript, ou avec un shader qui refuse de compiler, les deux blocs
   sont simplement là, dans leurs coins, en clair.
------------------------------------------------------------------- */

export type Paire = { haut: string[]; bas: string[] };

export type CendreProps = {
  /** Les paires de phrases, tirées à tour de rôle. */
  paires: Paire[];
  /** Rayon maximal du flou, en texels. Zéro : le texte se découvre net. */
  flou?: number;
  /** Demi-largeur du front, en parts du champ. Petit : une lame. Grand : un banc. */
  front?: number;
  /** Raideur du ressort d'entrée. La sortie est toujours plus raide. */
  ressort?: number;
  /** Temps de repos entre l'arrivée et la sortie, en millisecondes. */
  repos?: number;
  /** Amplitude de la parallaxe au curseur, en parts de la texture. */
  relief?: number;
  className?: string;
};

/* Un ressort critiquement amorti : l'amortissement est calé sur la
   raideur, donc il n'y a qu'un seul nombre à régler. Au-dessus de 1, il
   n'oscille jamais : un texte qui rebondit n'est pas un texte qui se
   pose. */
const AMORTI = 1.12;
const SORTIE = 1.4; /* la sortie est 40 % plus raide que l'entrée */
const ARRIVE = 0.95;
const VIDE = 0.02;
const REPOS_SORTIE = 80;

const HG: [number, number] = [0.16, 0.18];
const BD: [number, number] = [0.84, 0.82];

function pas(x: number, v: number, cible: number, k: number, dt: number): [number, number] {
  const c = 2 * Math.sqrt(k) * AMORTI;
  const nv = v + (-k * (x - cible) - c * v) * dt;
  return [x + nv * dt, nv];
}

/** Lit une couleur du thème et la rend en trois flottants. */
function encre(el: HTMLElement, prop: string, repli: string): string {
  const v = getComputedStyle(el).getPropertyValue(prop).trim();
  return v || repli;
}

function versRGB(css: string): [number, number, number] {
  const m = css.match(/\d+(\.\d+)?/g);
  if (m && m.length >= 3 && css.startsWith("rgb")) {
    return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
  }
  let h = css.replace("#", "");
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  if (Number.isNaN(n)) return [1, 1, 1];
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

export default function CendreCarte({
  paires,
  flou = 16,
  front = 0.22,
  ressort = 16,
  repos = 320,
  relief = 0.006,
  className,
}: CendreProps) {
  const hote = useRef<HTMLDivElement>(null);
  /* Les réglages voyagent par une référence : la boucle les lit à chaque
     trame, et les changer ne remonte donc ni le contexte ni la texture.
     Un curseur qu'on fait glisser ne doit pas recompiler un shader. */
  const reg = useRef({ flou, front, ressort, repos, relief, paires });
  reg.current = { flou, front, ressort, repos, relief, paires };
  /* L'index est aussi dans React : le texte du DOM suit celui de la
     texture, donc ce qu'un lecteur d'écran annonce est ce qui est peint. */
  const [i, setI] = useState(0);

  useEffect(() => {
    const el = hote.current;
    if (!el) return;

    const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    /* La braise du front reprend l'encre du texte : le front est un
       endroit où le texte est en train d'arriver, pas une couleur de
       plus. Un ton pris ailleurs dans le thème donne une frange qui
       n'appartient à rien. */
    const bord = versRGB(encre(el, "--fg", "#efeae9"));
    const teinte = encre(el, "--fg", "#efeae9");

    let L = el.clientWidth || 1;
    let H = el.clientHeight || 1;
    let police = getComputedStyle(el).fontFamily || "sans-serif";

    const toile = new Toile();
    if (!toile.disponible) return;
    el.dataset.gl = "true";
    toile.taille(L, H, dpr);
    el.appendChild(toile.canvas);

    let index = 0;
    let graine = 1.7;

    const composer = () => {
      const p = reg.current.paires[index % reg.current.paires.length];
      toile.texture(
        dessinerTexte({
          haut: p.haut,
          bas: p.bas,
          police,
          encre: teinte,
          largeur: L,
          hauteur: H,
          dpr,
        })
      );
    };
    composer();

    /* ---------- Le curseur ---------- */
    let cibleX = 0.5, cibleY = 0.5, curX = 0.5, curY = 0.5;
    let uv: [number, number] = [-1, -1];
    let survolC = 0, survolV = 0;
    const surLaMain = (e: PointerEvent) => {
      /* Une main, pas un doigt : un appui sur téléphone donnerait un
         creux qui reste là où le doigt a quitté l'écran. */
      if (e.pointerType !== "mouse") return;
      const b = el.getBoundingClientRect();
      cibleX = (e.clientX - b.left) / b.width;
      cibleY = (e.clientY - b.top) / b.height;
      uv = [cibleX, cibleY];
      survolV = 1;
    };
    const sortie = () => {
      cibleX = 0.5;
      cibleY = 0.5;
      survolV = 0;
    };
    el.addEventListener("pointermove", surLaMain);
    el.addEventListener("pointerleave", sortie);

    /* ---------- La boucle ---------- */
    let phase: "entree" | "repos" | "sortie" = "entree";
    let depuis = 0;
    let x = 0, v = 0, cible = 1;
    let horloge = 0, avant = 0, raf = 0, tourne = false;

    const trame = () => {
      if (!tourne) return;
      const t = performance.now();
      const dt = Math.min(0.05, Math.max(0.001, (t - avant) / 1000));
      avant = t;
      horloge += dt;

      const r = reg.current;
      [x, v] = pas(x, v, cible, cible > 0.5 ? r.ressort : r.ressort * SORTIE, dt);

      if (phase === "entree") {
        if (x >= ARRIVE) { phase = "repos"; depuis = t; }
      } else if (phase === "repos") {
        if (t - depuis >= r.repos) { phase = "sortie"; depuis = t; cible = 0; }
      } else if (x <= VIDE && t - depuis >= REPOS_SORTIE) {
        index = (index + 1) % r.paires.length;
        setI(index);
        graine = ((graine * 1.618) % 7) + 0.3;
        composer();
        phase = "entree";
        depuis = t;
        x = 0;
        v = 0;
        cible = 1;
      }

      /* Deux lissages exponentiels indépendants du pas de temps : la
         planche suit la main sans jamais la rattraper d'un coup. */
      const k = 1 - Math.pow(0.0009, dt);
      curX += (cibleX - curX) * k;
      curY += (cibleY - curY) * k;
      survolC += (survolV - survolC) * (1 - Math.pow(0.002, dt));

      const amp = r.relief * survolC;
      toile.peindre({
        progres: Math.max(0, Math.min(1, x)),
        flou: r.flou,
        front: r.front,
        bord,
        temps: horloge,
        ratio: L / Math.max(1, H),
        graine,
        parHG: [(curX - HG[0]) * amp, (curY - HG[1]) * amp],
        parBD: [(curX - BD[0]) * amp, (curY - BD[1]) * amp],
        curseur: uv,
        survol: survolC,
        retour: phase === "sortie" ? 1 : 0,
      });

      raf = requestAnimationFrame(trame);
    };

    const fige = () =>
      toile.peindre({
        progres: 1, flou: 0, front: reg.current.front, bord, temps: 0,
        ratio: L / Math.max(1, H), graine, parHG: [0, 0], parBD: [0, 0],
        curseur: [-1, -1], survol: 0, retour: 0,
      });

    const partir = () => {
      if (tourne) return;
      tourne = true;
      phase = "entree";
      depuis = avant = performance.now();
      x = 0; v = 0; cible = 1;
      raf = requestAnimationFrame(trame);
    };
    const arreter = () => {
      tourne = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    let vue = false, cache = false;
    const accorder = () => {
      if (calme) return;
      if (vue && !cache) partir();
      else arreter();
    };

    const io = new IntersectionObserver(
      (e) => { vue = e[0]?.isIntersecting ?? false; accorder(); },
      { threshold: 0.15 }
    );
    io.observe(el);
    const surVisibilite = () => { cache = document.hidden; accorder(); };
    document.addEventListener("visibilitychange", surVisibilite);

    /* Une seule remesure par redimensionnement, et différée : recomposer
       la planche à chaque pixel de largeur reviendrait à recomposer du
       texte soixante fois par seconde pendant qu'on tire une fenêtre. */
    let minuteur = 0;
    const ro = new ResizeObserver(() => {
      window.clearTimeout(minuteur);
      minuteur = window.setTimeout(() => {
        const l = el.clientWidth || 1, h = el.clientHeight || 1;
        if (l < 2 || h < 2 || (l === L && h === H)) return;
        L = l; H = h;
        toile.taille(L, H, dpr);
        composer();
        if (calme) fige();
      }, 140);
    });
    ro.observe(el);

    /* La police du site est chargée après le premier rendu : composer
       avant qu'elle arrive donne une planche en police système, et
       personne ne comprend pourquoi le texte a changé de visage. */
    if (document.fonts?.load) {
      document.fonts.load(`500 1em ${police}`).then(
        () => {
          police = getComputedStyle(el).fontFamily || police;
          composer();
          if (calme) fige();
        },
        () => {}
      );
    }

    if (calme) fige();

    return () => {
      arreter();
      io.disconnect();
      ro.disconnect();
      window.clearTimeout(minuteur);
      document.removeEventListener("visibilitychange", surVisibilite);
      el.removeEventListener("pointermove", surLaMain);
      el.removeEventListener("pointerleave", sortie);
      toile.detruire();
      toile.canvas.remove();
      delete el.dataset.gl;
    };
  }, []);

  const p = paires[i % paires.length];

  return (
    <div ref={hote} className={className ? `cnd ${className}` : "cnd"}>
      <p className="cnd__hg">
        {p.haut.map((l, k) => (
          <span key={k}>{l}</span>
        ))}
      </p>
      <p className="cnd__bd">
        {p.bas.map((l, k) => (
          <span key={k}>{l}</span>
        ))}
      </p>
    </div>
  );
}
Réglages
styles.css
.lueur {
  --lue-h: 288px;
  --lue-foyer: 162%;
  --lue-portee: 145%;
  --lue-faisceaux: 0.64;
  --lue-ellipse: 108%;
  --lue-filtre: none;
}

LueurAmbient glow

Deux boîtes vides et un masque, qu'on prend pour une lumière de scène.

/* ------------------------------------------------------------------
   Lueur · la lumière du pied de page.

   Ce qu'on croit voir : une image, ou un shader. Ce que c'est : deux
   boîtes vides et un masque. Aucun `canvas`, aucun SVG, aucun filtre,
   et pas une ligne de JavaScript. Le composant est rendu sur le serveur
   et n'est jamais hydraté.

   DEUX CALQUES, et ils ne font pas le même métier.

   La BRAISE est un dégradé radial dont le centre est SOUS le bord bas de
   la boîte. On ne voit donc jamais le foyer, seulement sa chute : la
   lumière vient de plus bas que la page, ce qui est exactement ce qu'il
   faut à l'endroit où la page s'arrête. Un foyer visible, c'est un
   soleil, et un soleil demande qu'on le regarde.

   Les FAISCEAUX sont une image, cadrée sur sa moitié diffuse et posée en
   `screen` : son noir ne peint rien, ses seules valeurs claires
   s'ajoutent à la braise. C'est ce calque qui donne la matière : les
   stries, les creux entre les faisceaux. Sans lui, la braise n'est qu'un
   dégradé, et ça se voit.

   Le MASQUE fait les trois bords à la fois : il dissout le HAUT de la
   boîte, et il éteint les deux COINS. Un dégradé linéaire ne sait faire
   que le premier, et une lumière allumée d'un bord à l'autre de l'écran
   n'a pas de centre de gravité : c'est une barre, pas une lampe.

   TOUT SE RÈGLE PAR VARIABLES. Rien n'est écrit en dur dans la feuille
   du composant : cinq propriétés personnalisées portent la hauteur, la
   profondeur du foyer, la portée de la braise, la force des faisceaux et
   l'ouverture du masque. C'est ce qui permet à la page de placer la
   lumière sans se disputer la spécificité avec le kit, et au bac à
   sable du Livre de la régler en direct, sans jamais re-rendre l'arbre.
------------------------------------------------------------------- */

export type LueurProps = {
  /** Classe de placement. Le composant ne se positionne pas lui-même. */
  className?: string;
  /**
   * Les cinq réglages passent par le style en ligne, sous forme de
   * propriétés personnalisées : `--lue-h`, `--lue-foyer`, `--lue-portee`,
   * `--lue-faisceaux`, `--lue-ellipse`.
   */
  style?: React.CSSProperties;
  /**
   * `true` (défaut) : la lumière attend d'entrer dans la fenêtre pour se
   * lever. Elle se confie à l'observateur du site, celui des titres et
   * des paragraphes : un observateur pour tout le document, pas un de
   * plus pour une lumière. `false` : elle est simplement là.
   */
  reveal?: boolean;
};

export default function Lueur({
  className,
  style,
  reveal = true,
}: LueurProps) {
  return (
    <div
      className={className ? `lue ${className}` : "lue"}
      /* C'est la BOÎTE qui est observée, jamais les calques : un élément
         mis à `scaleY(0)` a une aire nulle, et un observateur qui mesure
         une aire nulle ne déclenche jamais son seuil. */
      data-reveal={reveal ? "" : undefined}
      style={style}
      aria-hidden
    >
      <i className="lue__braise" />
      <i className="lue__faisceaux" />
    </div>
  );
}

Reprendre ce code

Copiez le .tsx et le .css, renommez ce que vous voulez, sans me demander. Si vous butez dessus, écrivez-moi à rogiermatteo@gmail.com, je réponds.

Le reste du site se visite depuis l'index, et le code complet est sur GitHub.

Sedan, disponible pour de nouveaux projets7 planches, sans dépendance