Lueur
Deux boîtes vides et un masque, qu'on prend pour une lumière de scène.
.lueur {
--lue-h: 288px;
--lue-foyer: 162%;
--lue-portee: 145%;
--lue-faisceaux: 0.64;
--lue-ellipse: 108%;
--lue-filtre: none;
}Fiche
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.
.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
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.
.lue__faisceaux {
background: var(--lue-image, url("/bg/curtain.webp")) center bottom / cover
no-repeat;
opacity: var(--lue-faisceaux);
mix-blend-mode: screen;
}
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.
--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);
}
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: screenle sait, parce que multiplier les inverses laisse le noir neutre. Sans lui, le second calque ne peut qu'assombrir le premier.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 : undiv, unpointerdown, de l'arithmétique surgetBoundingClientRect. 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
rangefait voyager le CENTRE de sa poignée depoignée/2àlargeur - poignée/2, donc la poignée est réduite à deux pixels et la plage mesurep × (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>
);
}
/* Lueur · src/components/book/kit/Lueur.tsx */
/* La boîte, et les cinq nombres qui font l'image.
La hauteur passe par une VARIABLE, et c'est la seule façon fiable de
la laisser régler du dehors. Une classe de placement posée sur le même
élément a exactement la même spécificité que `.lue` : à égalité, c'est
l'ordre des feuilles qui tranche, et cet ordre-là est décidé par le
bundler, pas par nous. Une feuille de kit chargée par une page passe
après la feuille globale, et la hauteur du kit gagne en silence. Une
variable ne se dispute avec personne : le kit lit, la page écrit. */
.lue {
position: relative;
height: var(--lue-h, clamp(13rem, 32vh, 23rem));
overflow: hidden;
pointer-events: none;
/* Profondeur du foyer sous le bord bas, et portée de la braise. Le
rayon horizontal suit le vertical de quinze points : la lumière est
un peu plus large que haute, comme une lampe posée au sol. */
--lue-foyer: 162%;
--lue-portee: 145%;
/* Force des faisceaux. */
--lue-faisceaux: 0.64;
/* Ouverture du masque. Le rayon horizontal suit de dix points. */
--lue-ellipse: 108%;
/* La part du masque restée pleinement opaque, mesurée depuis le foyer.
Au-delà, il descend jusqu'à zéro sur tout le reste de la course. */
--lue-plein: 14%;
/* La couleur : une rotation de teinte, appliquée aux deux calques. Voir
la note sur `.lue > i`. Non renseignée, elle ne coûte rien. */
--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);
}
.lue > i {
position: absolute;
inset: 0;
display: block;
/* Une lumière se lève du sol. */
transform-origin: 50% 100%;
/* La couleur, et c'est le seul réglage qui ne soit pas un nombre.
Elle ne se choisit pas arrêt par arrêt. Les huit couleurs de la
braise sont RELEVÉES, pas composées : ce sont les centiles de
l'histogramme de l'image des faisceaux, et les retoucher une à une
défait le relevé : 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 calques du même
angle. C'est la seule opération qui garde les deux moitiés de la
lumière d'accord.
`none` par défaut, et c'est délibéré : `hue-rotate(0deg)` reste un
filtre, donc un calque de composition et une passe de rendu pour ne
rien changer. La page d'accueil ne paie pas ce qu'elle n'utilise
pas. Le filtre est posé ici plutôt que sur la boîte : un filtre et
un masque sur le même élément, c'est un chemin de rendu que tous les
moteurs n'empruntent pas de la même façon. */
filter: var(--lue-filtre, none);
}
/* La braise. Ses huit arrêts ne sont pas choisis : ce sont les centiles
de l'histogramme de l'image des faisceaux. Les deux calques sont donc
la même lumière, et non deux rouges qui se ressemblent. */
.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
deux extrêmes : sur un bandeau large il cale l'image en largeur et
laisse déborder le haut, donc le point de fuite sort du champ ; sur un
téléphone il cale en hauteur et rentre dans les faisceaux du milieu.
Une taille en pourcentage ferait l'un OU l'autre, et laisserait un vide
sous la barre au format qui ne lui va pas.
`screen` et pas une opacité seule : le fond de l'image est noir, et un
noir posé en normal CREUSE la braise au lieu de l'éclairer. */
.lue__faisceaux {
background: var(--lue-image, url("/bg/curtain.webp")) center bottom / cover
no-repeat;
opacity: var(--lue-faisceaux);
mix-blend-mode: screen;
}
/* ---------- La montée ----------
Sans JavaScript, la lumière est simplement là. C'est la règle de la
maison : l'animation est l'agrément, l'image est le contrat. Le `.js`
posé avant le premier rendu est ce qui autorise l'état plat.
C'est la présence de `data-reveal` qui arme la montée, et son passage à
`data-in="true"` qui la joue. L'attribut est posé par l'observateur du
site : un seul pour tout le document, aucun code d'entrée ici.
La courbe est celle du site : `cubic-bezier(0.16, 1, 0.3, 1)`, un
`ease-out` très franc : la plus grande part de la course est faite dans
le premier tiers du temps, et la fin est une longue décélération. C'est
ce déséquilibre qui fait lire une lumière qui se lève plutôt qu'un
rectangle qu'on étire. */
.js .lue[data-reveal] > i {
transform: scaleY(0);
}
.js .lue[data-reveal][data-in="true"] > i {
animation: lue-leve var(--lue-montee, 1100ms)
var(--ease-swift, cubic-bezier(0.16, 1, 0.3, 1)) both;
}
@keyframes lue-leve {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
/* Mouvement réduit : la lueur reste, la montée s'en va. Une image de fond
n'a aucune raison de disparaître pour qui demande moins de mouvement :
c'est le mouvement qu'on retire, pas la page. */
@media (prefers-reduced-motion: reduce) {
.js .lue[data-reveal] > i,
.js .lue[data-reveal][data-in="true"] > i {
animation: none;
transform: none;
}
}