J’ai passé une bonne partie de ma carrière front à réparer des thèmes sombres bricolés à la va-vite : une variable ici, un !important là, un fichier dark.css dupliqué qui vit sa vie et diverge du thème clair au fil des sprints. À chaque fois, le même constat : le problème n’est pas la couleur, c’est l’absence de structure. Voici la méthode que j’applique aujourd’hui sur mes projets clients, et que je ne renie plus.
Un token n’est pas une couleur
La première erreur, c’est de confondre une valeur brute (#1e40af) et un token. Un design token, c’est une intention nommée : « la couleur de mon texte principal », pas « du bleu foncé ». Je travaille donc sur deux couches. La couche primitive contient les valeurs brutes, jamais utilisées directement dans les composants. La couche sémantique contient les intentions, et c’est la seule que touchent mes sélecteurs.
:root {
/* Couche 1 : primitives (jamais utilisees telles quelles) */
--gray-900: #111418;
--gray-50: #f7f8fa;
--blue-500: #2563eb;
--blue-300: #93c5fd;
/* Couche 2 : semantique (l'API de mes composants) */
--color-bg: var(--gray-50);
--color-text: var(--gray-900);
--color-accent: var(--blue-500);
}Le jour où le client veut passer d’un bleu à un violet, je change une primitive. Le jour où le contraste du texte secondaire ne passe pas l’audit d’accessibilité, je touche un token sémantique. Les composants, eux, ne bougent jamais : ils consomment var(--color-text) et se taisent.
Un composant qui référence une couleur brute est une dette technique qui s’ignore. Un composant qui référence un token sémantique est un contrat.
Le thème sombre, c’est juste une redéfinition
Une fois cette séparation en place, le mode sombre devient presque ennuyeux — et c’est exactement ce qu’on veut. Je ne duplique rien : je redéfinis la couche sémantique. Les primitives restent, les composants restent, seule la table de correspondance change.
1. Respecter la préférence système
Le point de départ, c’est prefers-color-scheme. C’est la préférence exprimée par l’utilisateur au niveau de son OS, et l’ignorer est un manque de respect basique.
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--gray-900);
--color-text: var(--gray-50);
--color-accent: var(--blue-300);
}
}2. Laisser l’utilisateur forcer un mode
La préférence système ne suffit pas : beaucoup de gens veulent un toggle explicite. J’ajoute donc un attribut data-theme sur <html>, piloté par un bouton. L’attribut gagne toujours contre le média, ce qui me donne la priorité que je veux.
:root[data-theme="dark"] {
--color-bg: var(--gray-900);
--color-text: var(--gray-50);
--color-accent: var(--blue-300);
}
:root[data-theme="light"] {
--color-bg: var(--gray-50);
--color-text: var(--gray-900);
--color-accent: var(--blue-500);
}Le seul bout de JavaScript nécessaire tient en trois lignes : lire la préférence dans localStorage, écrire data-theme sur document.documentElement le plus tôt possible pour éviter le flash au chargement. C’est tout. Pas de librairie, pas de contexte React qui re-render la moitié de l’arbre.
<head>, avant tout CSS, qui pose data-theme. C’est le seul endroit où j’accepte un script bloquant sans broncher.N’oubliez pas color-scheme
Un détail que trop de gens zappent : la propriété CSS color-scheme. Elle indique au navigateur quels schémas votre page supporte, et il ajuste alors ses widgets natifs — barres de défilement, champs de formulaire, <select>, popups de date. Sans elle, vous avez un thème sombre magnifique et une scrollbar blanche qui hurle.
:root { color-scheme: light dark; }Depuis mai 2024, cette base ouvre aussi la porte à la fonction light-dark(), désormais Baseline widely available. Elle permet de définir les deux valeurs en une seule déclaration, sans média query :
:root { color-scheme: light dark; }
.card {
background: light-dark(var(--gray-50), var(--gray-900));
color: light-dark(var(--gray-900), var(--gray-50));
}C’est élégant, mais soyez lucides sur le compromis : light-dark() suit la préférence système et le color-scheme, pas un attribut data-theme. Pour un toggle manuel, je reste sur la redéfinition de tokens. Je réserve light-dark() aux couleurs ponctuelles qui n’ont pas besoin de token dédié. La spec est détaillée sur MDN — light-dark() CSS function.
Ma checklist avant de livrer
- Aucune couleur brute dans un composant — que des tokens sémantiques.
- Trois entrées : préférence système, mode forcé clair, mode forcé sombre.
color-schemedéclaré pour les widgets natifs.- Script anti-FOUC synchrone dans le
<head>. - Contraste vérifié dans les deux thèmes, pas juste le clair.
Et un mot sur l’outillage : quand le nombre de tokens explose, je génère les custom properties depuis un fichier source unique (JSON ou YAML) plutôt que de les maintenir à la main. Je le fais volontiers en TypeScript avec un petit script de build, lancé depuis mes outils CLI habituels. La source de vérité vit dans un seul endroit, le CSS n’est plus qu’un artefact.
Les erreurs que je ne fais plus
- Dupliquer un fichier
dark.css: il divergera, c’est mathématique. - Mettre la logique de thème dans le state d’un framework : le CSS s’en occupe mieux et plus vite.
- Oublier que le sombre n’est pas « le clair avec les couleurs inversées » : les ombres, les élévations et les accents demandent leur propre réglage.
Ce que j’en retiens
Un thème clair/sombre maintenable ne tient pas à une astuce, mais à une discipline : deux couches de tokens, une couche sémantique qu’on redéfinit, et le CSS moderne (color-scheme, light-dark()) qui fait le gros du travail à notre place. Le JavaScript se réduit à poser un attribut. Si votre thème sombre vous fait peur à chaque refonte, ce n’est pas la faute du sombre — c’est que vos couleurs vivent au mauvais endroit. Remettez-les dans des tokens, et le reste suit.
