Le Livre

Cendre

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

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}
/>

Fiche

Chapitre
Surface
Nom courant
Shader text reveal
Publié le
9 septembre 2026
Fichiers
Cendre.tsx, cendre-gl.ts, Cendre.css
Dépendances
Aucune

Pourquoi

Le texte de la carte ci-dessus 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 dans une 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. À partir de là, ce n'est plus de la typographie, c'est une image.

C'est le détour qui rend l'effet possible. Un fondu qui dissout du texte en volutes doit trancher pixel par pixel, et le DOM ne sait pas faire ça : il connaît des boîtes, pas des pixels. On peut découper le texte en lettres et animer chaque lettre : l'effet est autre, plus mécanique, et il coûte un nœud par caractère. Une texture et un shader coûtent un nœud, que le texte fasse quatre mots ou quarante.

Trois champs se superposent pour décider quand un pixel se découvre : une DIAGONALE, qui donne le sens général de la marche ; une TURBULENCE, un fbm déformé sur lui-même, qui donne les volutes ; et un GRAIN fin, qui casse les contours restés trop lisses. Leur somme est comparée à l'avancement. Au-delà, le pixel est encore caché ; en deçà, il est découvert. Entre les deux, une bande où il est à la fois partiellement opaque et flou : c'est le front, et c'est le curseur « Front » qui en règle la largeur. Serrez-le à quatre centièmes : le nuage redevient une lame.

cendre-gl.tsL126–137
float champ = mix(diag, turb, 0.28);
champ = mix(champ, grain, 0.14);

/* Le curseur creuse le champ : le texte se découvre plus tôt là où
   la main se trouve. Au repos, « uSurvol » vaut zéro et la ligne est
   sans effet. */
vec2 cur = (vUV - uCurseur) * vec2(uRatio, 1.0);
float pres = 1.0 - smoothstep(0.0, 0.32, length(cur));
champ -= pres * uSurvol * 0.28;

/* « 1 - smoothstep(a, b, x) », et jamais « smoothstep(b, a, x) ». Les
   deux dessinent la même rampe descendante, mais la seconde passe
Les trois champs, leur somme, et la comparaison qui décide. Tout l'effet tient dans ces lignes.

Le flou décroît vers zéro DERRIÈRE le front, et c'est ce qui distingue cette planche d'un fondu. Le texte ne se contente pas d'apparaître : il fait le point. Chaque partie sort floue de la cendre et se résout à mesure qu'elle est découverte. Mettez « Flou » à zéro dans la carte : le dessin reste, la sensation part. Le texte se découpe au lieu de se former.

Le flou est un anneau de seize prélèvements, deux couronnes de huit dont la seconde à double rayon. Ce n'est pas un flou gaussien, et il n'a pas à l'être : sur des lettres, à ces rayons, personne ne fait la différence, et un vrai gaussien séparable demanderait deux passes et une cible de rendu intermédiaire. Le shader sort tôt quand le rayon est négligeable, si bien que tout ce qui est déjà net ne coûte qu'un seul prélèvement.

Le temps est un RESSORT, pas une durée, et c'est la deuxième décision de la planche. Un cubic-bezier sait aller d'un point à un autre en un temps donné : parfait 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 reprise à mi-course qui ne saute pas, et une sortie qu'on peut rendre plus raide que l'entrée sans changer de vocabulaire.

Cendre.tsxL78–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];
}
Cinq lignes. L'amortissement est calé sur la raideur, donc il n'y a qu'un nombre à régler.

La sortie est quarante pour cent plus raide que l'entrée, et ce n'est pas un caprice : le texte se forme lentement et s'efface vite, ce qui est le rythme de la lecture, pas celui d'une courbe symétrique. Une machine à quatre états fait le reste (entrée, repos, sortie, échange), et à chaque échange une GRAINE nouvelle décale le bruit. Sans elle, le nuage se dissout deux fois de la même façon, et à la troisième on ne regarde plus.

Rien de tout cela ne tourne pour personne : hors écran, onglet caché, mouvement réduit, la boucle s'arrête. Une boucle requestAnimationFrame qui tourne pour un onglet qu'on ne regarde pas 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 JavaScript, sans WebGL, ou avec un shader qui refuse de compiler, les deux blocs sont simplement là, dans leurs coins, en clair, et le composant n'a rien à faire pour ça : il lui suffit de ne rien faire.

Ce qui coince

  • smoothstep aux bornes inversées : indéfini, donc invisible. Pour dessiner une rampe DESCENDANTE, on écrit volontiers smoothstep(haut, bas, x) : on inverse les bornes et on lit à l'envers. Ça marche : la division par un dénominateur négatif tombe juste, et tous les pilotes de cartes graphiques rendent exactement ce qu'on attend. La spécification GLSL, elle, déclare le résultat INDÉFINI quand edge0 >= edge1. Le rendu logiciel a renvoyé zéro. Résultat : rendu valait zéro partout, chaque fragment était discard, et la carte était vide, sans erreur de compilation, sans avertissement, sur une seule famille de machines. Un moteur qui applique la spécification à la lettre a le droit de faire ça, et c'est le pire genre de bogue : celui qui ne se voit pas là où on développe. 1.0 - smoothstep(bas, haut, x) dessine la même courbe et n'est indéfini nulle part.
    cendre-gl.tsL144
    float rendu = 1.0 - smoothstep(p - uFront, p + uFront, champ);
    
    La même rampe, et définie partout.
  • Le repli n'est pas un repli, c'est le rendu par défaut. L'ordre est celui-ci et pas l'inverse : le texte est écrit en HTML, dans ses deux coins, avec sa mise en page ; ensuite, et seulement si le contexte existe et si le shader compile, la toile vient par-dessus et la copie en clair passe à zéro d'opacité. Un composant qui construit son repli APRÈS avoir échoué a toujours un chemin d'échec qu'il n'a pas prévu. Celui-ci n'en a aucun : ne rien faire donne déjà la bonne page.
    Cendre.cssL72–76
    .cnd[data-gl="true"] .cnd__hg,
    .cnd[data-gl="true"] .cnd__bd {
      opacity: 0;
      pointer-events: none;
    }
    
  • Une opacité nulle, pas une visibilité cachée. visibility: hidden retire un nœud de l'arbre d'accessibilité. Une carte dont tout le texte est peint dans un canevas ne dirait alors plus rien à personne : le canevas n'est pas annoncé, et la seule copie lisible venait d'être masquée. Une opacité nulle laisse le texte annoncé tout en le rendant invisible. C'est aussi pourquoi l'index des paires vit dans un état React et pas seulement dans la boucle : ce qu'un lecteur d'écran annonce est exactement ce qui est peint.
  • Les réglages passent par une référence, jamais par les dépendances. Premier montage naïf : les cinq réglages en dépendances de l'effet. Chaque cran de curseur détruisait le contexte WebGL, recompilait le shader et recomposait la texture, soixante fois par seconde pendant qu'un doigt glisse. La boucle les lit maintenant dans une référence mise à jour à chaque rendu : le composant ne se remonte jamais, et régler le flou ne coûte pas plus qu'un uniforme de plus à envoyer.
    Cendre.tsxL115–116
    const reg = useRef({ flou, front, ressort, repos, relief, paires });
    reg.current = { flou, front, ressort, repos, relief, paires };
    
  • Un contexte WebGL ne se ramasse pas tout seul. Le navigateur en tolère une poignée par page, souvent seize. Une carte qu'on monte et démonte au fil de la navigation les épuise, et le seizième contexte échoue en silence : pas d'erreur, une toile vide, et un composant qui marchait très bien il y a trois pages. WEBGL_lose_context le rend explicitement au démontage. C'est deux lignes, et c'est la différence entre une démonstration et un composant.
  • Le curseur n'écoute que la souris. La planche penche vers le pointeur, d'un seul décalage partagé, pas un par lettre : c'est une plaque posée sur une table qu'on incline, pas un mobile. Mais un pointermove reçoit aussi les doigts, et un appui sur téléphone laissait un creux permanent là où le doigt avait quitté l'écran, sans rien pour l'en faire partir. Le test sur pointerType règle la question en une ligne : sans souris, la planche reste droite, ce qui est exactement ce qu'on veut d'un effet de survol.

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

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

Ailleurs dans le Livre

Sedan, disponible pour de nouveaux projetsSurface · publié le 9 septembre 2026