Lueur

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

Réglages
styles.css
.lueur {
  --lue-h: 288px;
  --lue-foyer: 162%;
  --lue-portee: 145%;
  --lue-faisceaux: 0.64;
  --lue-ellipse: 108%;
  --lue-filtre: none;
}

Fiche

Chapitre
Surface
Nom courant
Ambient glow
Publié le
9 septembre 2026
Fichiers
Lueur.tsx, Lueur.css
Dépendances
Aucune

Pourquoi

C'est la lumière du bas de cette page, et de toutes les autres. On la cherche du côté d'un canvas, d'un shader, d'une image de fond. Il n'y a rien de tout ça : deux <i> vides, un dégradé radial, une image déjà chargée, un masque. Zéro octet de JavaScript : le composant est rendu sur le serveur et n'est jamais hydraté. Les curseurs de la carte ci-dessus règlent les six variables du fichier, et le bloc du bas est ce que vous pouvez coller dans une feuille.

Le premier calque est une BRAISE : un dégradé radial dont le centre est posé sous le bord bas de la boîte. C'est tout le sujet. On ne voit 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. Remontez « Foyer » jusqu'à cent pour cent dans la carte : le foyer entre dans le champ, et la lumière d'ambiance devient un soleil. Un soleil demande qu'on le regarde ; une lumière d'ambiance, non.

Lueur.cssL74–88
.lue__braise {
  background: radial-gradient(
    calc(var(--lue-portee) + 15%) var(--lue-portee) at 50% var(--lue-foyer),
    #e61015 0%,
    #c81714 10%,
    #a2080c 22%,
    #730105 36%,
    #4c0003 50%,
    #210102 66%,
    #0a0000 82%,
    transparent 96%
  );
}

/* Les faisceaux. `cover` ancré en bas est le seul cadrage qui tienne aux
Le foyer est le seul nombre qui compte : au-dessus de cent pour cent, il est hors champ.

Les huit couleurs ne sont pas composées, elles sont relevées. Le site a déjà une nappe de lumière ancrée en HAUT du document, et deux sources qui ne s'éclairent pas l'une l'autre se lisent comme une erreur. Ce sont donc huit centiles de l'histogramme de cette image, mesurés sur ses 1 684 × 634 pixels, du premier au plus vif. Le relevé dit quelque chose qu'aucune intuition n'aurait donné : la nappe ne contient ni jaune, ni rose, ni blanc. Elle monte du noir au rouge-orangé et s'arrête là, sa luminance plafonnant à 132 sur 255 : elle n'atteint jamais la lumière franche. Une braise à cœur crème, qui semblait pourtant du même monde, appartenait en fait à une autre pièce.

Le second calque est cette même image, et c'est là que se joue la différence entre une lumière et un dégradé. La braise seule est lisse : elle n'a ni stries, ni creux, ni accident. Les FAISCEAUX lui rendent sa matière. Posez « Faisceaux » à zéro dans la carte, et regardez ce qui s'en va : ce n'est pas de la luminosité, c'est de la texture.

Le mode de fusion n'est pas un ornement. Le fond de l'image est noir, et un noir posé en normal CREUSE la braise au lieu de l'éclairer : on obtient une tache sombre en travers de la lumière. En screen, le noir ne peint rien et seules les valeurs claires s'ajoutent. C'est la propriété qui fait que deux calques deviennent une seule lumière au lieu de deux images empilées.

Lueur.cssL97–102
.lue__faisceaux {
  background: var(--lue-image, url("/bg/curtain.webp")) center bottom / cover
    no-repeat;
  opacity: var(--lue-faisceaux);
  mix-blend-mode: screen;
}
`cover` ancré en bas : le seul cadrage qui tienne du bandeau large au téléphone.

Le cadrage mérite ses deux lignes. cover ancré en bas cale l'image en largeur sur un écran large : le point de fuite des faisceaux sort alors du champ par le haut, et il n'en reste que la moitié diffuse, celle qu'on veut. Sur un téléphone, le même mot cale en hauteur et rentre dans les faisceaux du milieu. Une taille en pourcentage ferait l'un OU l'autre, et laisserait une bande vide sous l'image au format qui ne lui va pas.

Reste le masque, et il travaille sur trois bords à la fois. Un dégradé linéaire dissout le haut de la boîte, sans quoi la lumière se termine par une arête horizontale en travers de la page, le défaut qui trahit une image collée en fond. Mais il ne sait faire que ça, 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. Une ellipse ancrée en bas au milieu éteint les deux coins DANS LE MÊME GESTE. Descendez « Ellipse » sous quatre-vingts pour cent : la lumière se referme en flaque. Montez-la au-delà de cent cinquante : elle redevient une barre.

Lueur.cssL33–40
  --lue-masque: radial-gradient(
    calc(var(--lue-ellipse) + 10%) var(--lue-ellipse) at 50% 100%,
    #000 var(--lue-plein),
    transparent 100%
  );
  -webkit-mask-image: var(--lue-masque);
  mask-image: var(--lue-masque);
}
Une ellipse, pas une rampe. Le haut et les coins sont le même problème.

La couleur est le sixième réglage, et le seul qui ne soit pas un nombre. Elle ne se choisit pas arrêt par arrêt, pour deux raisons qui vont dans le même sens. Les huit couleurs de la braise sont relevées, pas composées : les retoucher une à une défait le relevé, et l'on obtient huit couleurs qui se ressemblent au lieu d'une lumière. Surtout, le second calque est une IMAGE : aucune réécriture du dégradé ne la suivrait, et la braise partirait au bleu pendant que les faisceaux resteraient rouges. Une rotation de teinte, elle, s'applique aux deux du même angle : c'est la seule opération qui garde les deux moitiés de la lumière d'accord. Poussez « Teinte » dans la carte : les faisceaux tournent avec la braise, au degré près.

Un filtre non renseigné vaut none, et ce n'est pas un détail d'écriture. hue-rotate(0deg) reste un filtre : un calque de composition et une passe de rendu pour ne rien changer. La page d'accueil, qui garde la lumière telle qu'elle est, ne paie donc rien. C'est aussi pour cela que la teinte et l'éclat se partagent une seule variable au lieu d'en prendre deux : un filtre ne s'écrit pas en deux déclarations, et deux variables auraient forcé le composant à composer une chaîne qu'il ne pourrait plus laisser vide.

Tout ce qui précède se règle par variables, et c'est une décision d'architecture, pas de confort. Le kit ne peut pas se placer lui-même : la page décide de sa hauteur, de sa force, de son ouverture. Or une classe de placement posée sur le même élément a exactement la même spécificité que celle du composant : à égalité, c'est l'ordre des feuilles qui tranche, et cet ordre est décidé par le bundler. Une variable ne se dispute avec personne. Le kit lit, la page écrit, et le bac à sable de cette planche écrit exactement au même endroit que la page d'accueil.

Le mouvement est une seule ligne : scaleY de zéro à un, origine en bas, mille cent millisecondes, cubic-bezier(0.16, 1, 0.3, 1). Cette courbe fait la plus grande part de la course dans le premier tiers du temps et passe le reste à décélérer. C'est ce déséquilibre qui fait lire une lumière qui se lève plutôt qu'un rectangle qu'on étire ; un ease-in-out sur le même trajet donne un store qu'on remonte. Le bouton « Rejouer » ne fait rien d'autre que remonter le composant : l'observateur du site le retrouve et la montée repart.

Ce qui coince

  • Ce n'était pas ça, au début : c'étaient neuf rectangles. La première version reproduisait le fond de Dia Browser : neuf rectangles floutés, hauts au centre et courts sur les bords, tous peints du même dégradé vertical. Un SVG de quelques kilo-octets qu'on prend pour une aurore, et une jolie leçon : le flou était réglé pour ARRONDIR les arêtes des barres sans les effacer, si bien qu'on comptait les neuf marches à l'œil nu. Sur la référence, cette construction visible fait la différence entre un objet dessiné et une tache lumineuse.
  • Un escalier de 340 px n'est pas un escalier de 1 440 px. L'erreur n'était pas dans le composant, elle était dans son emploi. Ce qui se lit comme une construction dans une vignette se lit comme un histogramme sur toute la largeur d'une page : à 1 440 px, les neuf marches font 160 px chacune, le flou de quinze unités ne couvre plus qu'un tiers de leur bord, et la bande chaude du dégradé traverse l'écran d'un trait. Un composant n'est jamais bon ou mauvais dans l'absolu : il est bon à une échelle. Celui-là l'était à celle d'une carte, pas à celle d'un pied de page.
  • Le noir d'une image de fond creuse ce qu'il recouvre. Premier essai des deux calques : l'image posée sur la braise avec une simple opacité. Résultat, une bande sombre en travers de la lumière : le fond noir de l'image, moyenné avec le rouge en dessous. Une opacité ne sait pas qu'un noir est un vide ; mix-blend-mode: screen le sait, parce que multiplier les inverses laisse le noir neutre. Sans lui, le second calque ne peut qu'assombrir le premier.
    Lueur.cssL101
    mix-blend-mode: screen;
    
    Une déclaration. C'est elle qui fait des deux calques une seule lumière.
  • La lumière ne coûte pas une requête. Les faisceaux sont l'image qui éclaire déjà le haut du document. Elle est préchargée dans l'en-tête, elle est en cache avant qu'on arrive en bas de page, et le navigateur ne la redemande pas. Le second calque le plus riche du composant pèse donc zéro octet de réseau, et les deux bouts du document se trouvent éclairés par la même lampe, ce qui n'était pas le but mais s'est révélé être la meilleure part.
  • Un curseur qu'on écrit à la main, on le réécrit trois fois. Les cinq réglages de la carte sont des input type="range" natifs, posés à plat sur leur rangée et rendus transparents. La tentation était d'en dessiner un : un div, un pointerdown, de l'arithmétique sur getBoundingClientRect. Deux cents lignes, et il faut ensuite redonner une à une les flèches, Début et Fin, Page haut et Page bas, la capture du pointeur quand le doigt sort de la boîte, le sens de lecture inversé, le lecteur d'écran qui veut savoir où l'on est sur quelle échelle. L'élément natif a tout ça, partout, pour zéro octet. On lui retire sa peinture, on garde sa mécanique.
  • Un repère qui flotte finit toujours par barrer un mot. Premier dessin du curseur : un trait de deux pixels, quelque part sur la rangée, comme sur la référence. Il marche à toutes les valeurs sauf celles qu'on regarde : au dixième de la course, il tombe en plein sur le nom du réglage, et un mot barré par une ligne verticale se lit comme un défaut d'affichage, pas comme une valeur. Le repère est donc devenu le BORD DROIT d'une plage remplie : là où il y a une frontière, l'œil ne cherche pas une rature, et le texte peut passer par-dessus. En prime, la plage dit la course parcourue, ce qu'un trait ne disait pas. Reste l'accord au pixel : un range fait voyager le CENTRE de sa poignée de poignée/2 à largeur - poignée/2, donc la poignée est réduite à deux pixels et la plage mesure p × (100% - 2px) + 2px. Le bord est alors exactement sous la poignée, à toute valeur.

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.

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

Ailleurs dans le Livre

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