Home Projets Articles Profil AI Studio Me contacter
Design System · · 6 min de lecture

Dark mode & theming : un design system multi-thème propre

Implémenter un dark mode, c'est souvent la première vraie mise à l'épreuve d'un design system. Si l'architecture tient, la bascule est transparente. Si elle ne tient pas, chaque nouveau thème devient une dette supplémentaire. Voici comment construire un theming qui résiste.

Illustration abstraite : une composition scindée noir et blanc cassé reliée par une vague orange

1. Le dark mode n'est pas « inverser les couleurs »

L'erreur la plus courante quand on attaque un dark mode pour la première fois : appliquer un filtre CSS invert(1) sur le body, ou prendre chaque couleur light et en calculer le négatif. Le résultat est techniquement prévisible mais visuellement faux. Un fond blanc à 100 % devient un noir à 100 %, trop profond, fatigant, qui tue le contraste subtil. Les photos et illustrations ressemblent à des négatifs photo d'un autre âge.

Un bon dark mode est une décision de design indépendante. La luminosité des surfaces en mode sombre suit une logique d'élévation : le fond principal est le plus sombre, les cartes et panneaux flottants sont plus clairs. C'est l'inverse de la logique light où les ombres portées indiquent l'élévation. En dark, c'est la luminosité qui joue ce rôle. Un composant surface/card sera légèrement plus clair que le fond pour se détacher visuellement, sans ombre, sans border.

Les couleurs d'accentuation changent aussi. Un orange lave #FF4D00 sur fond blanc lit à 3.5:1. Sur fond très sombre il peut monter à 4.8:1 et gagner en présence. À l'inverse, un jaune qui passe facilement sur blanc peut devenir illisible sur noir. Chaque couleur sémantique doit être revue dans son contexte sombre, pas simplement copiée.

2. Les tokens sémantiques qui flippent automatiquement

La clé d'un theming propre : la séparation entre tokens primitifs et tokens sémantiques. Un token primitif décrit une valeur brute, neutral-900: #0B0B0B. Un token sémantique décrit un rôle, surface/background: neutral-900 en light, neutral-50 en dark. Les composants ne consomment que des tokens sémantiques. Jamais des primitifs directement.

Dans Figma, cette architecture se traduit par deux collections : une collection de primitives (les valeurs fixes) et une collection sémantique qui référence les primitives. Chaque mode de la collection sémantique (Light, Dark, Brand A, Brand B) pointe vers des primitives différentes. Changer de thème devient une bascule de mode, pas une réécriture.

Les quatre familles de tokens qui doivent absolument flipper :

  • surface/*, fond de page, cartes, modales, drawers. En light : blanc, gris très clair. En dark : noir désaturé, surfaces élevées légèrement plus claires.
  • text/* (ou content/*), texte principal, secondaire, désactivé, erreur, inversé. Ce sont ces tokens qui déterminent si le texte reste lisible dans les deux modes.
  • border/*, séparateurs, bordures de champs, outlines de focus. Ils passent d'un gris léger en light à un blanc très transparent en dark.
  • action/*, couleurs des boutons primaires, des liens, des états hover et pressed. L'intensité peut varier entre les modes pour maintenir le contraste.

Un token primitif décrit ce qu'une couleur est. Un token sémantique décrit ce qu'elle fait. Seul le second peut changer de valeur sans casser un composant.

Principe de base du theming en design system

3. La bascule de thème sans surcharge

Côté code, la solution la plus légère et la plus robuste combine un attribut data-theme sur le <html> et des CSS custom properties. Pas de JavaScript lourd, pas de classe utilitaire à répliquer sur chaque composant. Une seule déclaration suffit à tout rebrancher.

/* Tokens light, valeurs de base */
:root {
  --surface-bg: #F2F1EE;
  --surface-card: #FFFFFF;
  --text-primary: #0B0B0B;
  --text-secondary: #5A5653;
  --border-subtle: rgba(11,11,11,.13);
  --action-bg: #FF4D00;
}

/* Tokens dark, remplacent les mêmes propriétés */
[data-theme="dark"] {
  --surface-bg: #0B0B0B;
  --surface-card: #1A1A1D;
  --text-primary: #F2F1EE;
  --text-secondary: #A8A4A0;
  --border-subtle: rgba(242,241,238,.12);
  --action-bg: #FF4D00; /* inchangé, contraste OK dans les 2 modes */
}

/* Transition douce sur la bascule */
body {
  background: var(--surface-bg);
  color: var(--text-primary);
  transition: background .4s ease, color .4s ease;
}

La bascule JavaScript lit la préférence système au premier chargement (prefers-color-scheme) et persiste le choix de l'utilisateur en localStorage. Un script inline dans le <head>, avant tout chargement de CSS, évite le flash de contenu non thémé :

// Script inline, AVANT le premier CSS
try {
  const saved = localStorage.getItem('theme');
  const pref  = window.matchMedia('(prefers-color-scheme: dark)').matches;
  document.documentElement.dataset.theme = saved || (pref ? 'dark' : 'light');
} catch(e) {}

4. Les pièges récurrents

Une fois l'architecture en place, les bugs de theming les plus courants ne viennent pas de la structure, ils viennent des exceptions qui ont glissé dans le code au fil du temps.

  • Valeurs hardcodées sans variante. Un color: #0B0B0B directement dans un composant ne flippe jamais. Il reste noir en dark mode, devient invisible sur fond sombre. La seule règle qui protège : zéro couleur littérale dans les composants, uniquement des var(--token-*).
  • Sections inversées qui utilisent un token qui flippe. Une bande sombre dans une page light, type snackbar ou hero inversé, a souvent une background: var(--surface-bg). En dark mode, ce token devient le fond de page clair. La bande ne contraste plus. La solution : créer un token dédié surface/revert dont la valeur est figée, ou inverser explicitement le thème sur ce fragment avec un data-theme local imbriqué.
  • Texte sur section inversée. Corollaire du piège précédent : le texte d'une bande sombre utilise un token de texte clair (text/revert). Si le fond flippe mais pas le texte, les deux deviennent du même niveau de luminosité. Résultat : texte invisible. Texte et fond d'une section inversée doivent toujours se flip ensemble, ou ne pas se flip du tout.
  • Scrims et overlays sur images. Un dégradé linear-gradient(to top, rgba(0,0,0,.7), transparent) pensé pour le light peut devenir trop intense ou trop léger en dark. Les overlays d'images gagnent à utiliser un token de teinte et d'opacité plutôt qu'une valeur rgba figée.
  • Icônes SVG avec fill hardcodé. Un SVG inline avec fill="#0B0B0B" reste noir. Si le fond devient sombre, l'icône disparaît. Utiliser fill="currentColor" et laisser le token de texte parent piloter la couleur.

Piège classique

Une section « fond sombre » en light mode utilise background: var(--surface-revert). Ce token vaut #121212 en light. En dark mode, si ce token flippe aussi vers #FFFBFB pour « s'adapter », la section devient blanche, le contraste disparaît et le texte blanc devient illisible. Un token de surface inversée ne doit pas flipper : sa valeur doit rester fixe dans les deux modes, ou être redéfinie explicitement pour chaque mode selon l'intention design.

5. Contraste et accessibilité dans les deux modes

Le WCAG AA exige un ratio de contraste minimal de 4.5:1 pour le texte courant et de 3:1 pour le texte large (18px+ en gras ou 24px+ en normal) et pour les composants d'interface interactifs. Ces seuils s'appliquent dans chaque mode séparément. Il ne suffit pas qu'un composant passe en light, il doit passer en dark aussi.

Les pièges de contraste spécifiques au dark mode incluent les textes secondaires. En light, un texte secondaire à #6B6B6B sur blanc donne environ 5.7:1, correct. Si on convertit naïvement cette valeur en « ton clair » pour le dark (#A8A4A0 sur #0B0B0B), le ratio tombe à 4.1:1, en dessous du seuil. La nuance de secondaire doit être recalibrée indépendamment pour chaque mode.

Le focus visible est particulièrement critique en dark mode. Un outline 2px solid #0B0B0B pensé pour le light devient invisible sur fond sombre. Le ring de focus doit utiliser un token, idéalement border/active ou une couleur d'accentuation, et être testé dans les deux thèmes. La règle concrète : ne jamais écrire outline: 2px solid currentColor sur un fond qui change, préférer une couleur de token fixe ou à contraste garanti dans chaque mode.

  • Tester en forced-colors (Windows High Contrast, macOS Increase Contrast) : les thèmes système remplacent les tokens par des valeurs système. Un design system bien construit doit rester lisible dans ce mode sans intervention.
  • Auditer avec axe-core ou Lighthouse après chaque changement de palette de theming, en light ET en dark. Automatiser dans la CI si possible.
  • Ne pas s'appuyer uniquement sur la couleur pour transmettre un état : ajouter une icône, un motif, une forme. En dark mode, les nuances de couleur sont souvent plus subtiles qu'en light, ce qui rend les distinctions purement chromatiques encore moins fiables.

Conclusion

Un theming propre n'est pas une fonctionnalité qu'on ajoute à la fin : c'est une contrainte d'architecture qu'on pose au début. Tokens primitifs séparés des tokens sémantiques, zéro valeur littérale dans les composants, tests de contraste systématiques dans chaque mode, ces trois points suffisent à éviter 90 % des régressions que j'ai vues sur des projets multi-thème.

La vraie difficulté n'est pas technique. C'est organisationnelle : tenir la discipline dans la durée, quand la pression de livraison pousse à « juste mettre la couleur en dur pour aller plus vite ». Un design system qui tient la cohérence en dark comme en light, avec plusieurs marques, sur la durée, c'est exactement ce que j'ai outillé sur un grand design system. Si vous construisez ce type d'architecture, je suis disponible pour en discuter.

Discutons-en
Tous les articles