Attente
Neuf pixels, un mot et un chronomètre, et pas une seule trame demandée.
<Attente
mot="Traitement"
motif="chasse"
/>Fiche
Pourquoi
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. Celle-ci en dit trois choses, et c'est tout ce qui la distingue : CE QUI TRAVAILLE, QUE ÇA VIT, et DEPUIS QUAND.
Le mot d'abord, parce que c'est le seul des trois qui demande un choix. Pas « Chargement », mais le verbe de ce qui se passe : lecture du disque, compilation, publication. Un mot qui change pendant l'attente dit qu'on avance ; un mot fixe dit seulement qu'on n'a pas planté. C'est la différence entre une attente qu'on supporte et une attente qu'on quitte.
La grille ensuite. Neuf cellules, une onde en chevron qui traverse les trois colonnes, et un détail qui fait tout : le cycle de l'animation est PLUS COURT que le temps de traversée. Deux fronts sont donc toujours en vol, et la grille n'est jamais éteinte. Un cycle plus long laisserait un temps mort à chaque tour, un battement d'arrêt qu'on lit, à tort, comme un blocage.
const CHASSE = Array.from({ length: 9 }, (_, i) =>
((i % 3) + Math.abs(Math.floor(i / 3) - 1)) * 90
);
Neuf éléments, et ZÉRO TRAME. Les neuf cellules sont animées par une seule règle @keyframes avec neuf retards : le compositeur s'en occupe, le fil principal n'est réveillé par rien. Une attente est précisément le moment où la machine a autre chose à faire : un témoin d'attente qui consomme des trames est un témoin qui rallonge ce qu'il annonce.
Le chronomètre, enfin, est la seule information honnête qu'on puisse donner sans connaître la fin. Il ne dit pas combien il reste, il dit depuis quand, et c'est ce qu'on cherche des yeux au bout de dix secondes, pour décider si l'on attend encore ou si l'on relance.
const t = window.setInterval(
() => setMs(performance.now() - depart.current),
100
);
La troisième variante, l'orbite, tient sur un vide : la cellule centrale ne s'allume jamais. L'allumer donnerait un scintillement ; la retirer ferait s'effondrer la grille sur huit cellules. Elle reste donc en place, à sa teinte la plus sourde, et c'est ce trou au milieu qui fait lire une rotation plutôt qu'un clignotement.
Ce qui coince
- Un chronomètre qui s'incrémente ment quand on le regarde. La version évidente ajoute cent millisecondes à un total à chaque réveil. Elle dérive : un
setIntervalen retard (onglet en arrière-plan, fil principal occupé, machine chargée) ne rattrape jamais ce qu'il a perdu. Sur trois secondes personne ne le voit ; sur trois minutes d'attente, l'écart se compte en secondes, et c'est exactement le moment où quelqu'un fixe le chiffre. Relire l'horloge coûte le même appel et ne dérive pas. - Des chiffres tabulaires, sinon la ligne tressaute. Sans
font-variant-numeric: tabular-nums, la largeur du nombre change à chaque dixième de seconde (un « 1 » est plus étroit qu'un « 8 »), et toute la ligne se déplace dix fois par seconde. Ce n'est pas une gêne théorique : un chronomètre qui gigote se lit comme un défaut d'affichage, et il attire l'œil pendant tout le temps où l'on voudrait qu'il se fasse oublier. - Le reflet passe DANS la lettre, pas derrière. Une bande claire qui balaie un bloc de texte se lit comme un chargement générique : on l'a vue mille fois sur des rectangles gris. La même bande peinte dans le tracé des lettres (un dégradé découpé par le texte) se lit comme une matière. C'est la même animation, appliquée à un autre calque, et la différence de registre est entière.
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; role="status", et le chronomètre retiré de l'annonce. Une attente s'annonce quand le lecteur d'écran a fini sa phrase : c'eststatus, pas une alerte, et surtout pasassertive. Et le chiffre en est exclu : laissé dans la région vivante, il serait relu dix fois par seconde. Ce qui doit être dit, c'est le mot ; le temps écoulé est une information pour l'œil qui balaie, pas pour l'oreille qui écoute.
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";
/* ------------------------------------------------------------------
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>
);
}
/* Attente · src/components/book/kit/Attente.tsx */
.att {
display: inline-flex;
align-items: center;
gap: 0.7em;
--att-px: 4px;
--att-jour: 1.5px;
}
/* ---------- La grille ----------
Trois colonnes de quatre pixels. Les neuf cellules sont animées par
une seule `@keyframes`, avec neuf retards : rien n'est piloté en
JavaScript, et le fil principal n'est réveillé par aucune trame.
Les cellules mortes de l'orbite gardent leur place et leur teinte la
plus sourde. Les retirer ferait s'effondrer la grille sur huit
cellules ; les allumer supprimerait le vide central qui fait lire une
rotation. */
.att__grille {
display: grid;
flex: none;
grid-template-columns: repeat(3, var(--att-px));
gap: var(--att-jour);
}
.att__px {
width: var(--att-px);
height: var(--att-px);
border-radius: 1px;
background: var(--fg);
opacity: 0.16;
animation: att-pixel var(--t, 650ms) ease-in-out var(--r, 0ms) infinite;
}
.att__grille[data-rond] .att__px {
border-radius: 50%;
}
.att__px[data-morte] {
opacity: 0.07;
animation: none;
}
@keyframes att-pixel {
0%,
100% {
opacity: 0.16;
}
38% {
opacity: 1;
}
}
/* ---------- Le mot ----------
Le reflet passe DANS la lettre, pas derrière : le dégradé est peint
sur le texte lui-même. Une bande claire qui traverse un bloc de texte
se lit comme un balayage de chargement générique ; la même bande dans
le tracé des lettres se lit comme une matière. */
.att__mot {
font-size: 0.92em;
font-weight: 500;
color: var(--muted-strong);
background-image: linear-gradient(
90deg,
var(--muted-strong) 34%,
var(--fg) 50%,
var(--muted-strong) 66%
);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: att-reflet 1.5s linear infinite;
}
@keyframes att-reflet {
from {
background-position: 130% 0;
}
to {
background-position: -30% 0;
}
}
/* Chiffres TABULAIRES, et c'est tout l'intérêt. Sans eux, la largeur du
nombre change à chaque dixième de seconde, et la ligne entière
tressaute : un chronomètre qui gigote se lit comme une erreur. */
.att__chrono {
font-family: var(--mono);
font-size: 0.82em;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Mouvement réduit : la grille se fige à son état sourd et le reflet
s'en va. Le chronomètre, lui, continue : c'est de l'information, pas
du mouvement, et c'est justement celle qu'on est venu chercher. */
@media (prefers-reduced-motion: reduce) {
.att__px,
.att__mot {
animation: none;
}
.att__mot {
-webkit-text-fill-color: var(--muted-strong);
}
}
/* En contraste forcé, un texte peint par un dégradé disparaît : le
système impose ses couleurs et le remplissage transparent gagne. */
@media (forced-colors: active) {
.att__mot {
background: none;
-webkit-text-fill-color: CanvasText;
}
}