Le mode sombre est simple jusqu’à ce qu’il ne le soit plus. La première fois que votre app clignote en blanc à 2h du matin sur un téléphone OLED, vous le ressentirez. La seconde fois, c’est pire : quelqu’un ouvre un ticket parce que votre « mode sombre » rend les graphiques illisibles, se réinitialise à chaque rafraîchissement et casse l’impression. Si vous déployez des systèmes en production, les bugs de thème ne sont pas cosmétiques. Ce sont des bugs de confiance.
L’objectif ici est un modèle qui se comporte comme un service bien géré : il respecte la plateforme, offre une surcharge explicite à l’utilisateur, persiste de manière prévisible, évite le clignotement et reste testable. Vous implémenterez prefers-color-scheme correctement, ajouterez un toggle manuel et garderez l’ensemble maintenable quand votre design system prendra de l’ampleur.
À quoi ressemble le « bon » en production
Un système de thèmes n’est pas un défilé de mode. C’est un contrat entre le navigateur, l’OS, votre CSS, votre JavaScript et l’intention de l’utilisateur.
Incontournables
- Par défaut : suivre
prefers-color-scheme. - Override : un toggle manuel qui prime sur la préférence système.
- Persistance : mémoriser l’override entre les sessions, sans rester bloqué sur le passé.
- Pas de flash : le premier rendu doit déjà être le thème voulu.
- Accessible : contraste conforme, anneaux de focus visibles et toggle utilisable au clavier/avec lecteur d’écran.
- Composable : fonctionne dans un design system et à travers des micro-frontends sans trois « vérités » concurrentes.
Le mode d’échec le plus fréquent est de traiter le « mode sombre » comme une réflexion CSS après coup. Ça devient un empilement d’overrides, plus un petit script qui bascule une classe après le chargement. Ce n’est pas une fonctionnalité ; c’est une condition de course avec l’interface utilisateur.
Une citation pour rester honnête. L’espoir n’est pas une stratégie.
— Gene Kranz. (Oui, on l’a réutilisée jusqu’à la corde. Elle reste néanmoins pertinente.)
Petite blague #1 : si votre toggle de thème a besoin d’un spinner, vous n’avez pas construit un toggle — vous avez construit un système distribué.
Faits et historique à connaître
Le mode sombre n’est pas arrivé comme une fonctionnalité unique ; il s’est accumulé à partir de contraintes matérielles, de besoins d’accessibilité et de budgets énergétiques. Quelques points concrets qui influencent les décisions d’implémentation d’aujourd’hui :
- Les premiers « UI sombres » visaient phosphore et éblouissement : les interfaces terminales et les premiers écrans favorisaient le texte clair sur fond sombre en partie pour réduire l’éblouissement perçu dans des environnements peu éclairés.
- L’OLED a changé l’équation : sur OLED, les pixels noirs consomment souvent beaucoup moins d’énergie que les pixels blancs. Sur LCD, le rétroéclairage domine, donc les économies peuvent être négligeables.
prefers-color-schemeest un primitif web récent : il est devenu réellement utilisable quand les navigateurs modernes se sont alignés sur le support des media queries ; avant cela, chaque site inventait son propre toggle et sa logique de persistance.- Les design systems ont compliqué le problème : une fois que vous avez des tokens, des composants et plusieurs produits, l’approche « juste override quelques couleurs » ne scale plus.
- Les modes très contrastés existent depuis plus longtemps que le buzz du sombre : les paramètres OS de couleurs forcées et contrastes étaient déjà là ; beaucoup de déploiements de « mode sombre » les ont accidentellement cassés.
- L’impression est une partie prenante cachée : un fond sombre peut convenir à l’écran, mais être catastrophique sur papier ou pour des PDF, sauf si vous gérez explicitement les styles d’impression.
- Les graphiques et dataviz sont des victimes fréquentes : les gammes de couleurs, les lignes de grille et le contraste des annotations nécessitent un réglage séparé ; vous ne pouvez pas simplement inverser la page.
- Les apps d’entreprise adorent les iframes : les contextes embarqués (webviews, iframes) compliquent la propagation du thème et la persistance.
Le contrat de base : préférence système + override utilisateur
Voici le modèle qui fonctionne : considérez le thème comme une préférence à trois états, pas un booléen.
- System (par défaut) : suivre
prefers-color-scheme. - Light (override) : l’utilisateur force le clair.
- Dark (override) : l’utilisateur force le sombre.
Si vous stockez juste « dark=true/false » vous finirez par casser quelqu’un : l’utilisateur qui a basculé une fois il y a des mois, puis a changé la préférence OS plus tard, et maintenant se demande pourquoi votre app est désynchronisée. Le trois-états règle ça. Stockez theme=system|light|dark, et calculez le thème effectif à l’exécution.
Où le stocker
Vous avez deux lieux de persistance courants :
localStorage: le plus simple, côté client, par profil de navigateur. Suffisant pour la plupart des sites.- Cookie : nécessaire quand vous voulez que le SSR émette le thème correct dans la première réponse sans attendre le JS.
Choisissez un seul. N’écrivez pas dans les deux à moins d’aimer déboguer des bugs de précédence subtils à 3h du matin.
Comment l’appliquer
Utilisez un seul attribut faisant autorité sur <html> (ou <body>) comme data-theme="dark". Évitez de disperser des classes de thème sur les racines des composants. Chaque point de bascule supplémentaire est un potentiel split-brain.
Architecture CSS qui tiendra la route
N’implémentez pas le thème en réécrivant les styles des composants un par un. Vous mourrez d’une pluie de petites coupures. Thématisez via des tokens : des variables CSS qui représentent l’intention (surface, texte, bordure, accent), pas des couleurs brutes.
Utilisez des tokens sémantiques, pas des tokens « blue-500 »
Quand un product manager demande « un fond légèrement plus chaud en mode sombre », vous voulez changer une variable, pas chasser vingt valeurs hex. Un jeu de tokens pratique ressemble à ceci :
--color-bg,--color-surface--color-text,--color-muted--color-border--color-link,--color-link-visited--color-focus--shadow-elevation-1(oui, des tokens d’ombre aussi)
Pattern CSS de base
Définissez des valeurs par défaut dans :root et overridez par thème en utilisant un sélecteur d’attribut. Puis, optionnellement, superposez la préférence système quand l’utilisateur est en mode system.
cr0x@server:~$ cat theme.css
:root {
color-scheme: light dark;
--color-bg: #ffffff;
--color-surface: #f6f7f9;
--color-text: #111827;
--color-muted: #4b5563;
--color-border: #d1d5db;
--color-focus: #2563eb;
}
:root[data-theme="dark"] {
--color-bg: #0b1220;
--color-surface: #0f172a;
--color-text: #e5e7eb;
--color-muted: #94a3b8;
--color-border: #243041;
--color-focus: #60a5fa;
}
@media (prefers-color-scheme: dark) {
:root[data-theme="system"] {
--color-bg: #0b1220;
--color-surface: #0f172a;
--color-text: #e5e7eb;
--color-muted: #94a3b8;
--color-border: #243041;
--color-focus: #60a5fa;
}
}
html, body {
background: var(--color-bg);
color: var(--color-text);
}
Ce que cela vous apporte : un seul endroit pour définir les valeurs de thème, et un mécanisme de sélection propre et testable. Notez aussi color-scheme: light dark;. Ça indique au navigateur que vous supportez les deux, afin que les contrôles natifs et les barres de défilement puissent se rendre de manière appropriée dans de nombreux environnements.
Ne pas thématiser par inversion
Les filtres CSS comme filter: invert(1) sont un tour de fête. Ils cassent les images, détruisent les couleurs de marque et rendent les captures d’écran dignes d’une enquête paranormale.
Gérez explicitement les images et icônes
Pour les icônes, préférez le SVG avec fill="currentColor" pour qu’elles héritent de --color-text ou d’une couleur définie par token. Pour les images raster, décidez : restent-elles identiques, ou avez-vous une variante sombre ? Si ce sont des images critiques produit (cartes, diagrammes), vous aurez probablement besoin de variantes.
Le toggle : machine d’état, pas de l’impro
Votre JavaScript a trois missions :
- Déterminer la préférence stockée (
system,light,dark). - Définir
data-themeavant le premier rendu quand c’est possible. - Exposer une UI de bascule qui change la préférence stockée et met à jour le DOM.
Une implémentation minimale et fiable
Gardez la logique petite. Faites-la ennuyeuse. L’astuce appartient à votre produit, pas à la plomberie du thème.
cr0x@server:~$ cat theme.js
(function () {
const STORAGE_KEY = "theme-preference"; // "system" | "light" | "dark"
const root = document.documentElement;
function readPreference() {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch (e) {}
return "system";
}
function writePreference(value) {
try {
localStorage.setItem(STORAGE_KEY, value);
} catch (e) {}
}
function applyPreference(value) {
root.setAttribute("data-theme", value);
}
function cyclePreference(current) {
// Opinionated: cycle system -> light -> dark -> system
if (current === "system") return "light";
if (current === "light") return "dark";
return "system";
}
// Early apply on load
const initial = readPreference();
applyPreference(initial);
// Export small API for the button
window.theme = {
get: readPreference,
set: (v) => { writePreference(v); applyPreference(v); },
cycle: () => {
const next = cyclePreference(readPreference());
writePreference(next);
applyPreference(next);
return next;
}
};
})();
Cela est volontairement peu excitant. C’est un compliment. Le bouton de bascule peut appeler window.theme.cycle() et mettre à jour son label.
Écouter les changements système (mais seulement en mode system)
Si l’utilisateur a choisi system, c’est ce qu’il veut. S’il a choisi dark, il le veut vraiment. Réagissez donc aux changements d’OS uniquement quand la préférence stockée est system.
cr0x@server:~$ cat theme-system-listener.js
(function () {
const media = window.matchMedia("(prefers-color-scheme: dark)");
function onChange() {
const pref = window.theme && window.theme.get ? window.theme.get() : "system";
if (pref === "system") {
document.documentElement.setAttribute("data-theme", "system");
}
}
if (media.addEventListener) media.addEventListener("change", onChange);
else if (media.addListener) media.addListener(onChange);
})();
Notez ce qu’il ne fait pas : il ne réécrit pas le localStorage. Les changements de préférence système ne doivent pas écraser l’intention de l’utilisateur. Votre app doit simplement réévaluer les couleurs effectives via la media query en CSS.
Éliminer le flash (FOUC/FOWT) sans hacks
Le flash du mauvais thème est généralement auto-infligé : vous chargez du CSS qui dépend d’un attribut data-theme, mais vous ne définissez cet attribut qu’après le rendu de la page. Les utilisateurs voient le thème par défaut pendant une fraction de seconde. Sur un desktop rapide, c’est un clignotement. Sur un téléphone milieu de gamme avec cache froid, c’est un stroboscope.
Meilleure pratique : inliner un tout petit script de « bootstrap thème » dans le head
Oui, inline. Oui, avant votre CSS si possible. Ce n’est pas du « bloat JS » ; c’est de la correction. Gardez-le minuscule et synchrone.
cr0x@server:~$ cat theme-bootstrap-inline.js
(function () {
try {
var v = localStorage.getItem("theme-preference");
if (v !== "light" && v !== "dark" && v !== "system") v = "system";
document.documentElement.setAttribute("data-theme", v);
} catch (e) {
document.documentElement.setAttribute("data-theme", "system");
}
})();
Puis votre media query CSS pour system prend le relais. Le navigateur peut calculer les styles avant le premier rendu.
Et la CSP ?
Si votre CSP interdit les scripts inline, vous avez un compromis : accepter le clignotement, autoriser un petit script inline via nonce/hash, ou faire la sélection du thème côté serveur via des cookies. En entreprise, la CSP l’emporte souvent ; prévoyez-le tôt plutôt que de le découvrir après la revue sécurité.
Encore une chose : définir color-scheme
Même si vous maîtrisez le fond et le texte, les contrôles natifs peuvent être en retard. Déclarer color-scheme: light dark; dans :root aide les navigateurs à rendre les contrôles natifs dans un style correspondant. Ce n’est pas parfait partout, mais c’est peu coûteux et généralement correct.
SSR, hydration, et pourquoi le premier rendu compte
Les apps purement client peuvent se permettre un peu d’indécision. Les apps SSR ne le peuvent pas. Avec SSR, les utilisateurs voient HTML et CSS avant que votre bundle ne charge. Si votre serveur envoie du HTML en thème clair mais que le client décide du thème sombre après l’hydratation, vous obtenez une bascule visible et parfois des changements de mise en page (polices, bordures, images). On dirait que la page redémarre.
Options de décision côté serveur
- Override basé sur cookie : si un utilisateur a défini
theme=dark, le serveur peut rendre sombre immédiatement. - Préférence système : le serveur ne peut pas connaître de façon fiable
prefers-color-schemeà partir de la requête HTTP seule. Quelques nouveaux client hints existent, mais traitez-les comme optionnels, pas indispensables. - Hybride : le rendu serveur par défaut utilise
data-theme="system". Si un cookie indique un override, définirdata-themeen conséquence.
L’approche hybride fonctionne bien : laissez le CSS et les media queries gérer la préférence système ; laissez les cookies gérer les overrides explicites. Si vous faites cela, gardez la portée du cookie cohérente entre les sous-domaines qui partagent la même UI ; rien ne dit « suite produit cohésive » comme chaque sous-domaine qui re-décide vos yeux.
Mismatch d’hydratation : la classique piège
Si votre framework côté client rend du markup spécifique au thème (par ex. des SVG différents, des arbres de composants différents), et que le serveur a rendu l’autre thème, vous pouvez déclencher des warnings d’hydratation ou un re-render complet. La solution : garder le markup identique entre les thèmes autant que possible et varier la présentation via des variables CSS.
Accessibilité : contraste, focus et motion réduit
Le mode sombre peut être plus reposant pour certains yeux, pire pour d’autres. Il n’y a pas de « confort » universel. Votre travail est d’éviter de rendre l’UI illisible ou physiquement désagréable.
Le contraste n’est pas optionnel
Dans les thèmes sombres, les designers choisissent souvent du texte gris atténué sur fond presque noir. Ça fait « premium ». Ça échoue aussi les critères de contraste et transforme la lecture en effort. Si vous avez besoin d’un ton muté, utilisez-le parcimonieusement (labels secondaires), pas pour le contenu principal.
Les anneaux de focus doivent survivre au thème
Beaucoup d’équipes retirent les outlines pour des raisons esthétiques, puis oublient de les remettre. Un thème sombre sans focus visible est fonctionnellement cassé pour les utilisateurs au clavier. Utilisez un token comme --color-focus et gardez-le suffisamment lumineux pour les deux thèmes.
Respecter prefers-reduced-motion
Les transitions de thème (fondu entre thèmes) peuvent être élégantes. Elles peuvent aussi déclencher des sensibilités au mouvement si elles sont trop présentes. Respectez prefers-reduced-motion et désactivez ou raccourcissez les transitions.
cr0x@server:~$ cat motion.css
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
Petite blague #2 : si vous animez un changement de thème sur 600ms, vos utilisateurs auront le temps de se faire un thé et de reconsidérer votre produit.
Observabilité : mesurer adoption et régressions
Le mode sombre est une fonctionnalité produit. Cela signifie que vous devriez la mesurer. Pas de façon obsessive. Juste assez pour attraper des régressions et comprendre si le toggle fait ce que vous pensez.
Quoi logger (et quoi éviter)
- Logger : les transitions d’état de préférence (system → dark, dark → system).
- Logger : le thème effectif au premier rendu (si vous pouvez l’instrumenter), pour détecter les incidents de flash.
- Ne pas logger : « l’utilisateur a une préférence OS sombre » comme attribut lié à l’identité sans considérer la vie privée et la politique. C’est étonnamment fingerprintant combiné à d’autres signaux.
Une approche pratique : émettre un événement analytics lors de l’interaction utilisateur avec le toggle. Séparément, enregistrer des métriques client pour le « first paint theme match » échantillonné à faible taux. Si vous voyez le taux de mismatch augmenter après une release, vous saurez où regarder : script bootstrap, template SSR, ou ordre des CSS.
Trois mini-histoires d’entreprise depuis les tranchées du thème
1) L’incident causé par une mauvaise hypothèse
Une entreprise a livré un nouveau composant d’en-tête dans le cadre d’un rafraîchissement du design system. Il était superbe en mode clair. En mode sombre, il paraissait à peu près correct aussi — jusqu’à l’ouverture d’un menu déroulant. Le fond du menu était sombre, mais le texte du menu restait gris foncé. Ce n’était pas seulement un faible contraste ; c’était invisible.
L’hypothèse erronée était subtile : l’équipe croyait que le « mode sombre » se résumait à inverser les couleurs de fond et de texte au niveau de la page. Le composant dropdown, toutefois, avait des couleurs codées en dur dans une feuille de style imbriquée qui n’utilisait jamais de tokens. La librairie de composants « supportait le theming » dans le README, mais seulement au niveau du shell.
Les tickets de support sont arrivés en premier. Puis des utilisateurs internes ont commencé à faire des captures d’écran et à les coller dans le chat avec des annotations comme « est-ce un flag pour la cécité ». Un incident a été déclaré car le dropdown contrôlait un réglage de sécurité de compte. Les gens ne pouvaient pas se déconnecter ou gérer les sessions en mode sombre, et l’app passait par défaut en sombre pour une grosse partie des utilisateurs.
La correction n’a pas été héroïque. Ils ont introduit des tokens sémantiques et exigé que chaque style de composant les utilise, avec des règles de lint empêchant les couleurs brutes sauf dans les définitions de tokens. Ensuite ils ont ajouté des tests de régression visuelle pour les flux clés dans les deux thèmes. La leçon : les hypothèses sur le « themage global » s’effondrent dès qu’un composant est livré avec des couleurs codées en dur.
2) L’optimisation qui s’est retournée contre eux
Une autre équipe a voulu éliminer le petit script inline de bootstrap thème car leur budget perf était serré et l’équipe sécurité faisait la grimace face au JS inline. Ils ont donc déplacé l’initialisation du thème dans le bundle principal et utilisé defer. Ils ont aussi ajouté une jolie transition de fondu entre les thèmes pour que ce soit « premium ».
En labo, ça semblait correct. Sur de vrais appareils, c’était le chaos. Les utilisateurs sur réseaux lents voyaient un flash blanc, puis un fondu vers le sombre, puis un second flash quand l’hydratation remplaçait le markup rendu côté serveur. La transition a amplifié le problème : au lieu d’un bref clignotement, c’est devenu une animation notable qui attirait l’attention.
Ça s’est empiré dans des webviews embarquées dans une app native. La webview retardait parfois le bundle JS plus que prévu, donc le thème initial persistait pendant des secondes. Les gens pensaient que le toggle « ne fonctionnait pas », parce qu’il fonctionnait — finalement. Juste pas dans un délai humainement raisonnable.
Ils ont revert la transition, réintroduit un script inline CSP-haché, et livré un override basé cookie pour le SSR. Les performances se sont réellement améliorées parce que les utilisateurs ont arrêté de déclencher des rerenders et des shifts de layout causés par les flips de thème. La leçon : optimiser pour le mauvais indicateur (pureté du bundle) peut empirer la performance visible pour l’utilisateur.
3) La pratique ennuyeuse mais correcte qui a sauvé la mise
Une troisième organisation a fait quelque chose qui semblait douloureusement peu glamour : ils ont créé un doc de « contrat de thème » et une petite suite de tests de conformité pour les composants. Chaque composant devait rendre correctement sous data-theme="light", "dark", et "system" avec les deux réglages prefers-color-scheme dans le runner de tests.
Ils ont aussi standardisé la nomenclature des tokens et interdit les variables ad hoc dans le CSS des composants. Besoin d’une nouvelle nuance ? Ajoutez un token, justifiez-le, et branchez-le dans les deux thèmes. Ça a ralenti les premières PR. Les gens ont râlé. Puis les râleries ont cessé parce que les règles étaient prévisibles.
Un trimestre plus tard, un gros rebrand est arrivé : nouvelle couleur d’accent, nouvelle teinte de fond, ombres mises à jour. Les équipes s’attendaient à des cassures. Au lieu de ça, elles ont changé les valeurs de tokens, lancé les tests et livré. La discipline « ennuyeuse » a fait qu’ils n’ont pas eu à chasser des centaines de fichiers CSS à la recherche de valeurs hex comme des archéologues déterrant une mauvaise décision.
La leçon : le meilleur système de thème est surtout du process. Le code est la partie facile.
Tâches pratiques (avec commandes) pour déboguer et décider
Ce sont des tâches que vous pouvez lancer aujourd’hui sur une machine dev ou un runner CI. Chacune inclut : une commande, ce que la sortie signifie, et quelle décision en tirer. L’objectif est opérationnel : réduire le mystère, réduire les suppositions.
Tâche 1 : Vérifier que votre CSS build contient bien les sélecteurs de thème
cr0x@server:~$ rg -n 'data-theme="dark"|prefers-color-scheme' dist/assets/*.css
dist/assets/app.9c31.css:12::root[data-theme="dark"]{--color-bg:#0b1220;...}
dist/assets/app.9c31.css:38:@media (prefers-color-scheme: dark){:root[data-theme="system"]{...}}
Signification : Vous voyez à la fois l’override explicite et la media query pour le mode système dans l’artifact livré.
Décision : Si ces chaînes ne sont pas présentes, votre pipeline de build a supprimé ou n’a jamais inclus le CSS de thème. Corrigez l’ordre d’import ou la config du bundler avant de déboguer le comportement UI.
Tâche 2 : Détecter les hex codés en dur dans le CSS des composants (contournement de tokens)
cr0x@server:~$ rg -n --glob='**/*.css' '#[0-9a-fA-F]{3,8}\b' src/
src/components/dropdown.css:44:color: #111827;
src/components/dropdown.css:51:background: #ffffff;
Signification : Des composants contournent les tokens ; ils provoqueront probablement des cassures dans un thème.
Décision : Remplacez par des variables sémantiques (ex. var(--color-text), var(--color-surface)) et autorisez les hex bruts uniquement dans les fichiers de tokens.
Tâche 3 : Confirmer que la racine HTML a l’attribut attendu au repos
cr0x@server:~$ node -e "const {JSDOM}=require('jsdom'); const html='<!doctype html><html data-theme=\"dark\"></html>'; const dom=new JSDOM(html); console.log(dom.window.document.documentElement.getAttribute('data-theme'));"
dark
Signification : Vos templates/SSR peuvent définir l’attribut.
Décision : Si le SSR ne définit jamais data-theme, vous devez compter sur le bootstrap client et accepter un flash potentiel — ou implémenter une sélection via cookies côté serveur.
Tâche 4 : Valider le comportement de persistence localStorage dans un run headless
cr0x@server:~$ node -e "console.log('Simulate: read=system when empty, store=dark');"
Simulate: read=system when empty, store=dark
Signification : C’est une étape de sanity placeholder pour le scripting CI : assurez-vous que vos chemins de code gèrent les valeurs manquantes/invalides et ne plantent pas.
Décision : Si vous avez des exceptions lors de l’accès au stockage en mode privé ou environnements renforcés, ajoutez try/catch et utilisez system par défaut.
Tâche 5 : Vérifier les en-têtes CSP pour la faisabilité des scripts inline
cr0x@server:~$ curl -sI http://localhost:3000 | rg -i 'content-security-policy'
content-security-policy: default-src 'self'; script-src 'self'
Signification : Les scripts inline sont bloqués (pas de 'unsafe-inline', pas de nonce, pas de hash).
Décision : Soit ajoutez un nonce/hash pour le petit bootstrap, soit utilisez la sélection SSR basée cookie pour éviter le flash.
Tâche 6 : Confirmer que le serveur définit un cookie de thème quand l’utilisateur choisit un override
cr0x@server:~$ curl -sI http://localhost:3000/set-theme?value=dark | rg -i 'set-cookie'
set-cookie: theme=dark; Path=/; SameSite=Lax
Signification : Le serveur peut persister un override d’une manière que le SSR peut lire.
Décision : Si vous ne voyez pas Set-Cookie, le SSR ne peut pas connaître les overrides ; il vous faudra la voie du bootstrap inline.
Tâche 7 : Valider que le cookie est renvoyé sur la requête suivante
cr0x@server:~$ curl -sI --cookie "theme=dark" http://localhost:3000 | rg -i 'data-theme|set-cookie'
set-cookie: session=...; Path=/; HttpOnly; SameSite=Lax
Signification : Le cookie est présent et la requête a réussi. (Vous ne verrez pas data-theme dans les headers ; vérifiez le HTML ensuite.)
Décision : Récupérez ensuite le HTML et inspectez si le SSR a utilisé le cookie.
Tâche 8 : Inspecter le HTML SSR pour la correction de l’attribut de thème
cr0x@server:~$ curl -s --cookie "theme=dark" http://localhost:3000 | head -n 5
Signification : Le SSR rend immédiatement le thème correct.
Décision : Si le SSR renvoie encore system, votre serveur ne lit pas le cookie (ou l’ordre des middlewares est incorrect).
Tâche 9 : Confirmer que votre CSS déclare color-scheme pour aligner l’UI native
cr0x@server:~$ rg -n 'color-scheme:\s*light\s+dark' src/**/*.css
src/styles/theme.css:2: color-scheme: light dark;
Signification : Les navigateurs ont un indice pour thématiser les widgets natifs.
Décision : Si absent, ajoutez-le ; puis retestez les contrôles de formulaire et les barres de défilement sur les plateformes.
Tâche 10 : Attraper les overrides accidentels de thème depuis du CSS tiers
cr0x@server:~$ rg -n 'background:\s*#fff|color:\s*#000' node_modules/some-widget/dist/widget.css
node_modules/some-widget/dist/widget.css:88:background: #fff;
node_modules/some-widget/dist/widget.css:89:color: #000;
Signification : Une feuille de style fournisseur code des couleurs en dur et sera incorrecte en mode sombre.
Décision : Encapsulez-la (shadow DOM ou container avec overrides), patch via des variables CSS si possible, ou remplacez le widget. Ne supposez pas que ça se réparera seul.
Tâche 11 : Vérifier que les styles d’impression n’impriment pas une page noire
cr0x@server:~$ rg -n '@media\s+print' src/styles/*.css
src/styles/print.css:1:@media print {
Signification : Vous avez une gestion d’impression explicite.
Décision : Si absent, ajoutez une feuille d’impression qui force un fond clair et un texte foncé, quel que soit le thème, sauf si votre produit nécessite explicitement l’impression sombre.
Tâche 12 : Vérifier que reduced-motion est respecté pour les transitions de thème
cr0x@server:~$ rg -n 'prefers-reduced-motion' src/styles/**/*.css
src/styles/motion.css:1:@media (prefers-reduced-motion: reduce) {
Signification : Vous avez au moins considéré la sensibilité au mouvement.
Décision : Si absent et que vous avez des transitions sur les couleurs/fonds, implémentez la garde reduced-motion.
Tâche 13 : Sanity-check que votre toggle est accessible et labellisé (vérif statique)
cr0x@server:~$ rg -n 'aria-label="Theme"|aria-pressed|role="switch"' src/
src/components/ThemeToggle.tsx:18:<button aria-label="Theme" aria-pressed={...}>
Signification : Votre toggle expose probablement l’état aux technologies d’assistance.
Décision : Si pas d’étiquetage ou d’attribut d’état, corrigez avant shipping ; les régressions d’accessibilité ne sont pas des bugs « nice-to-have ».
Tâche 14 : S’assurer que le build n’a pas réordonné les CSS de façon à casser la précédence
cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 214K Dec 29 10:22 dist/assets/app.9c31.css
-rw-r--r-- 1 cr0x cr0x 48K Dec 29 10:22 dist/assets/vendor.1a02.css
Signification : Vous avez plusieurs fichiers CSS ; l’ordre importe.
Décision : Assurez-vous que les définitions de tokens se chargent avant le CSS des composants, et que les overrides de thème se chargent après les valeurs par défaut. Si le CSS fournisseur se charge en dernier, il peut écraser vos couleurs.
Playbook de diagnostic rapide
Quand le mode sombre est « cassé », ne commencez pas par réécrire le CSS. Commencez par trouver où la vérité diverge : préférence stockée, attribut DOM, tokens calculés, ou styles de composants.
Première étape : déterminer la préférence sélectionnée et le thème effectif
- Inspecter
document.documentElement.dataset.themedans la console DevTools. - Vérifier la valeur storage/cookie pour
theme-preference(ou votre clé choisie). - Vérifier la préférence OS/navigateur : la media query
prefers-color-scheme: darkcorrespond-elle à ce que vous pensez ?
Si la préférence est incorrecte : votre logique de toggle/persistance est cassée. Corrigez le JS et le stockage en premier.
Deuxième étape : vérifier le comportement du premier rendu (chasse au flicker)
- Hard refresh avec cache désactivé. Regardez pour un flash.
- Vérifiez si
data-themeest présent dans le HTML SSR ou défini tôt par un script inline. - Vérifiez la CSP : si les scripts inline sont bloqués et que le SSR ne définit pas le thème, vous aurez un flash.
Si le premier rendu est incorrect : déplacez la sélection du thème plus tôt (cookie SSR ou bootstrap inline).
Troisième étape : isoler les échecs de tokens CSS vs couleurs codées
- Inspectez un composant illisible et regardez les styles calculés : les valeurs viennent-elles de
var(--...)ou de couleurs littérales ? - Si couleurs littérales : le composant a contourné les tokens ou un CSS fournisseur l’a écrasé.
- Si les tokens sont utilisés mais faux : les valeurs de token ne sont pas override pour le thème actif.
Si les tokens ne s’overrident pas : corrigez la spécificité/ordre des sélecteurs (:root[data-theme="dark"] qui ne s’applique pas), et vérifiez l’ordre de chargement du CSS.
Erreurs courantes : symptômes → cause racine → correctif
1) Symptom : le mode sombre fonctionne après le toggle, mais revient au rafraîchissement
Cause racine : l’état est stocké en mémoire (état du framework), pas persisté ; ou l’écriture au stockage échoue (mode privé, stockage bloqué, exceptions).
Fix : persistez theme-preference dans localStorage avec try/catch ; par défaut à system quand le stockage est indisponible.
2) Symptom : la page clignote en clair puis passe au sombre
Cause racine : data-theme est appliqué après le premier rendu (bundle chargé tard), ou le SSR a rendu un thème différent de celui calculé côté client.
Fix : inline un script bootstrap minimal (nonce/hash si nécessaire), ou rendre l’override via cookie en SSR. Gardez le markup neutre au niveau du thème ; variez la présentation via des tokens.
3) Symptom : seuls certains composants changent de thème
Cause racine : composants avec couleurs codées en dur ou leur propre mécanisme de thème ; multiples racines de thème existent.
Fix : imposez l’utilisation de tokens ; faites de <html data-theme> la source unique de vérité ; retirez les classes concurrentes.
4) Symptom : les formulaires rendent mal (inputs blancs sur fond sombre)
Cause racine : absence de déclaration color-scheme ; contrôles natifs non informés ; styles de composants partiellement overridés.
Fix : définir color-scheme: light dark; dans :root ; styliser explicitement les contrôles de formulaire avec des tokens quand nécessaire.
5) Symptom : les graphiques sont illisibles en mode sombre
Cause racine : palette de datavis optimisée pour fond clair ; lignes/grilles/axes à faible contraste ; texte canvas/SVG non thématisé.
Fix : définir des tokens spécifiques aux graphiques (axe, grille, palette séries) et les changer par thème ; tester avec de vrais jeux de données, pas des démos.
6) Symptom : les changements de préférence système ne se reflètent pas en « system »
Cause racine : la media query CSS est absente ou écrasée ; l’app a stocké un binaire dark/light et ne réévalue jamais.
Fix : stocker la préférence à trois états ; utiliser @media (prefers-color-scheme: dark) pour les overrides de data-theme="system".
7) Symptom : le toggle est inaccessible ou déroutant
Cause racine : contrôle icône-only sans label ; état non communiqué (aria-pressed manquant) ; l’état « system » non représenté.
Fix : utiliser un bouton ou switch labellisé ; inclure les choix « System / Light / Dark » ou un cycle avec tooltip clair et nom accessible.
8) Symptom : impression/PDF encrasseuse ou pages noires
Cause racine : les styles du thème sombre s’appliquent à l’impression ; pas d’overrides pour l’impression.
Fix : ajouter du CSS d’impression qui force une palette claire et désactive les fonds décoratifs.
Checklists / plan étape par étape
Plan d’implémentation étape par étape (le modèle qui tient)
- Définir le modèle de préférence :
system|light|dark. Écrivez-le ; faites-en partie du contrat produit. - Choisir une seule voie de persistance :
localStoragepour client-only ; cookie si le SSR a besoin d’un premier rendu correct pour les overrides. - Implémenter une racine de thème unique :
<html data-theme="system">. Tout le reste s’y réfère. - Construire des tokens sémantiques : background, surface, texte, muted, bordure, focus, lien, ombres.
- Implémenter les overrides CSS :
:root[data-theme="dark"]et@media (prefers-color-scheme: dark) :root[data-theme="system"]. - Définir
color-scheme:color-scheme: light dark;dans:root. - Bootstrap inline (ou cookie SSR) : garantir que
data-themeest présent avant le premier rendu. - Construire l’UI de toggle : label accessible, exposition d’état (
aria-pressedourole="switch"), gestion claire desystem. - Auditer les composants pour couleurs brutes : supprimer ou mettre derrière des tokens.
- Tester les flux critiques dans les deux thèmes : auth, settings, tables, charts, modals, toasts, pages d’erreur.
- Gérer l’impression : forcer une palette adaptée à l’impression.
- Ajouter de l’observabilité : suivre l’usage du toggle et le taux de mismatch first-paint (échantillonné).
Checklist de release (vérifications avant livraison)
- Hard refresh sur réseau lent : pas de flash du mauvais thème.
- Le thème persiste au rafraîchissement et dans un nouvel onglet.
- Le mode système suit les changements OS sans écraser la préférence stockée.
- Formulaires, modals, dropdowns lisibles dans les deux thèmes.
- Anneaux de focus visibles et cohérents.
- Graphiques et tableaux passent le « squint test » en mode sombre.
- L’impression produit un rendu sain.
- Les widgets tiers ne cassent pas le thème de manière critique.
FAQ
Le toggle doit-il être à deux états ou à trois états (system/light/dark) ?
Le trois-états gagne dans la vraie vie. Le deux-états vous force à deviner ce que « off » signifie et rend les utilisateurs accrochés à un vieux choix quand la préférence OS change.
prefers-color-scheme suffit-il sans toggle ?
Non. Certains utilisateurs veulent le contraire de leur préférence système pour un site spécifique (travail vs appareils perso, bureaux lumineux, éblouissement). Donnez-leur une override.
Où doit vivre l’attribut thème : <html> ou <body> ?
<html> est la racine la plus propre et fonctionne bien avec les déclarations de tokens sur :root. Choisissez-en un et soyez cohérent.
Pourquoi ne pas simplement basculer une classe .dark ?
Vous pouvez, mais les sélecteurs d’attribut sont plus faciles à étendre (system/light/dark) et réduisent les collisions avec des classes non liées. Le vrai gain est la cohérence, pas la syntaxe.
Dois-je écouter les changements de thème système en JavaScript ?
Pas strictement, si votre CSS utilise @media (prefers-color-scheme) pour data-theme="system". Un listener peut aider à mettre à jour les labels/icônes de l’UI, mais ne réécrivez pas la préférence stockée.
Comment éviter le mismatch d’hydratation dans les frameworks SSR ?
Gardez la structure DOM identique entre les thèmes. Utilisez des variables CSS pour les différences de présentation. Si vous devez changer le markup, décidez du thème côté serveur via cookie pour les overrides.
Comment nommer les tokens ?
Nommer par intention : --color-surface, pas --gray-950. Votre futur vous remerciera quand la marque changera. Votre présent vous remerciera aussi, discrètement.
Le mode sombre économise-t-il toujours la batterie ?
Pas toujours. Sur OLED, souvent oui. Sur LCD, le rétroéclairage reste actif, donc les économies sont moindres. Livrez le mode sombre pour l’ergonomie et la préférence d’abord ; considérez les économies d’énergie comme un effet secondaire agréable.
Qu’en est-il des modes haute-contraste / couleurs forcées ?
Ne les combattez pas. Évitez les images de fond codées derrière du texte, gardez les styles de focus visibles et testez le comportement forced-colors. Si votre design casse là-bas, ce n’est pas un « edge-case » ; c’est une dette d’accessibilité.
Faut-il animer les transitions de thème ?
Généralement : non, ou très subtilement et désactivée si prefers-reduced-motion est activé. Le thème est un état, pas une boîte de nuit.
Conclusion : prochaines étapes à livrer
Un bon mode sombre est un problème de fiabilité déguisé en choix de design. Respectez la préférence système, offrez une override, persistez de façon prévisible et assurez le premier rendu. Utilisez des tokens pour que le système scale, et ajoutez juste assez d’observabilité pour détecter les régressions avant vos utilisateurs.
Prochaines étapes pratiques
- Implémenter la préférence trois-états et définir
data-themesur<html>. - Déplacer toutes les valeurs de thème dans des variables CSS sémantiques et supprimer les couleurs codées dans les composants.
- Ajouter soit un script bootstrap inline (CSP-safe via nonce/hash), soit une sélection SSR par cookie pour les overrides afin d’éliminer le flash.
- Lancer les audits basés grep (hex bruts,
color-schememanquant, CSS fournisseur qui écrase) et corriger les pires coupables en priorité. - Tester les flux critiques dans les deux thèmes et à l’impression, puis verrouiller le tout avec des checks automatisés.