Tokens sémantiques pour le changement de thème
Le changement de thème est un problème de nommage avant d’être un problème de
CSS. Les design tokens du mode sombre sont des rôles sémantiques —
background, surface, text-primary — où chaque rôle porte deux valeurs,
une par thème, et les composants ne référencent que le nom du rôle. Changez de
thème et chaque référence se ré-résout d’un coup ; aucun composant n’a
d’opinion sur le mode dans lequel il se trouve. Le risque d’ingénierie n’est
pas le changement mais le mapping : la table rôle-vers-valeur d’un thème sombre
n’est pas un miroir de celle du thème clair, elle doit donc être dérivée et
revérifiée, jamais inversée.
La structure en couches en dessous tient en un paragraphe :
les tokens primitifs nomment la matière première
(les échelons de la rampe — brand-600, neutral-100), les tokens
sémantiques nomment des tâches d’UI et pointent vers les primitifs, et les
composants ne consomment que la sémantique. Les arguments de nommage et la
mécanique du fichier de design ont leurs propres guides ; cet article — qui
fait partie de notre guide du mode sombre — porte sur ce
que fait la couche sémantique quand un second thème arrive.
Pourquoi chaque rôle a-t-il besoin de deux valeurs ?
Parce que le sens d’un rôle est stable et sa valeur ne l’est pas. background
signifie « la couleur de repos de la page » dans n’importe quel thème ; la
valeur qui remplit ce rôle est presque blanche en mode clair et presque noire
en mode sombre. La couche sémantique est la frontière du thème : tout ce qui se
trouve au-dessus — composants, mises en page, écrans — reste aveugle au thème,
et tout ce qui se trouve en dessous change en bloc.
C’est aussi la raison intuitive pour laquelle le motif fonctionne pour ceux qui l’utilisent. Un designer ou un développeur qui raisonne en rôles se demande à quoi sert cette surface, ce qui a une seule réponse ; raisonner en valeurs revient à demander quel gris est-ce, ce qui a deux réponses qu’il faut synchroniser à la main. Des noms qui promettent une tâche plutôt qu’une valeur, voilà ce qui fait du second thème un changement de données plutôt qu’un second design.
Pourquoi le mapping sombre n’est-il pas un miroir du clair ?
Le modèle mental tentant consiste à inverser l’échelle — si le mode clair
utilise neutral-100 pour la page, le mode sombre utilise son numéro opposé.
Dériver le mapping puis le vérifier produit quelque chose de plus intéressant.
Six rôles issus d’une palette générée à partir de #2563eb —
oklch(0.546 0.215 262.9) :
| Rôle | Clair | Sombre (dérivé) | Ce qu’un miroir direct dirait |
|---|---|---|---|
background | neutral-100 (L ≈0.94) | neutral-900 (L ≈0.22) | neutral-800 — mais la page s’ancre au bas de l’échelle pour que l’échelle d’élévation ait de la place au-dessus d’elle |
surface (carte) | neutral-50 (L ≈0.97) | neutral-800 (L ≈0.28) | neutral-900 — ce qui ferait couler la carte sous la page et inverserait l’élévation |
text-primary | neutral-800 (L ≈0.28) | neutral-100 (L ≈0.94) | neutral-100 — tient, même si les thèmes ajustés à la main adoucissent souvent trop vers neutral-200, qui paraît fin |
text-secondary | neutral-600 (L ≈0.47) | neutral-300 (L ≈0.78) | neutral-300 — le miroir tient |
border-subtle | neutral-200, plus sombre que la carte | neutral-700, plus clair que la carte | les mêmes échelons — mais la relation change de sens |
accent | brand-600 (≈#3E65B5) | ≈oklch(0.70 0.14 262.9) de la rampe sombre | brand-300 de la rampe claire — la mauvaise rampe entièrement |
Trois asymétries méritent d’être nommées. Premièrement, background et
surface croisent leurs miroirs : les surfaces surélevées doivent être plus
claires que ce sur quoi elles reposent en mode sombre, si bien que la carte se
place au-dessus de la page sur l’échelle de luminosité alors qu’un miroir
l’aurait placée en dessous. Deuxièmement, le texte : neutral-200 sur la page
sombre franchit encore le plancher arithmétique (≈12:1 contre l’exigence de
4,5:1), mais le texte clair sur fond sombre a tendance à paraître plus fin que
ne le suggèrent les chiffres — APCA, qui modélise la polarité, rapporte pour
cette paire une valeur inférieure à son jumeau du thème clair — de sorte que la
dérivation atterrit un échelon plus clair que ce que la prudence choisirait.
Troisièmement, la valeur sombre de l’accent ne provient pas du tout de la rampe
claire :
une palette sombre dérivée suit sa propre courbe de luminosité
avec un chroma augmenté d’environ 20 %,
parce qu’un environnement sombre atténue la coloration perçue.
La conséquence pratique : les deux mappings devraient être produits par une
seule dérivation, et non maintenus comme deux listes. L’export de tokens de
Scale Composer comporte une section semantic et une section semantic-dark
générées à partir des mêmes couleurs de départ — chaque rôle redérivé par
thème, les planchers WCAG revérifiés, l’APCA rapporté en parallèle, et les
rôles onFill revérifiés, puisqu’un fond qui portait du texte blanc en clair
peut vouloir du texte sombre en sombre.
Ouvrez l’export avec les deux sections sémantiques dans Scale Composer — les mêmes noms de rôle des deux côtés, les valeurs sombres visiblement pas un miroir des claires.

Quels motifs de changement de thème échouent à grande échelle ?
Trois motifs reviennent, et tous les trois échouent de la même façon — le mapping se disperse au lieu d’être déclaré une seule fois.
Des variantes dark: par composant. Le préfixe dark: de Tailwind
redéclare la valeur sombre à chaque point d’utilisation :
bg-white dark:bg-gray-900 sur chaque carte, dans chaque fichier. Pour un
petit site, ça va — tout le mapping tient sur un écran. Pour un système, c’est
une taxe de maintenance : changer ce que signifie « surface » en mode sombre
revient à un rechercher-remplacer dans toute la base de code, et tout composant
qui a oublié son jumeau dark: est livré cassé dans un thème.
if (isDark) dans le code du composant. Les conditions de thème déplacent
le mapping dans la logique, où il peut se ramifier, dériver et échapper à la
relecture. Un composant qui demande quel thème est actif a pris en charge une
responsabilité que la couche de tokens porte déjà.
Deux feuilles de style. Un dark.css maintenu à côté d’un light.css
commence comme une copie et dérive dès la première modification qui ne touche
qu’un seul fichier. Rien ne garantit que les deux fichiers répondent aux mêmes
questions.
Comment les propriétés personnalisées CSS portent-elles le changement ?
En redéclarant les mêmes noms sous une portée de thème. Les composants référencent la propriété personnalisée ; la valeur de la propriété dépend de la déclaration qui s’applique à ce moment — la mécanique se résume aux propriétés personnalisées CSS plus la cascade, rien de plus.
:root {
--color-background: oklch(0.94 0.008 262.9); /* neutral-100 */
--color-surface: oklch(0.97 0.005 262.9); /* neutral-50 */
--color-text-primary: oklch(0.28 0.02 262.9); /* neutral-800 */
--color-accent: oklch(0.546 0.215 262.9); /* brand core */
}
[data-theme="dark"] {
--color-background: oklch(0.22 0.015 262.9); /* neutral-900 */
--color-surface: oklch(0.28 0.02 262.9); /* neutral-800 */
--color-text-primary: oklch(0.94 0.008 262.9); /* neutral-100 */
--color-accent: oklch(0.70 0.14 262.9); /* dark ramp */
}
.card {
background: var(--color-surface);
color: var(--color-text-primary);
}
La règle .card est l’essentiel : elle apparaît une seule fois, ne mentionne
aucun thème, et est correcte dans les deux. Le bloc sombre peut tout aussi bien
s’accrocher à la media query prefers-color-scheme, ou — le motif robuste — la
requête définit la valeur par défaut et l’attribut la remplace ; les détails du
câblage font l’objet de leur propre article.
Quel que soit le déclencheur choisi, l’export CSS depuis une source de tokens
génère les deux blocs à partir de la même dérivation, et c’est ce qui empêche
les deux mappings de s’écarter l’un de l’autre.
Regardez un changement se résoudre
Le mapping est plus facile à croire après l’avoir vu bouger. Basculez l’aperçu du thème sur l’ensemble complet des rôles — chaque rôle sémantique se ré-résolvant d’un coup, les asymétries du tableau ci-dessus visibles sur place : la carte restant plus claire que la page, l’accent sautant vers un échelon plus clair d’une rampe redérivée, aucune décision au niveau du composant nulle part.