Développement Web

Dark mode et thèmes par tokens avec Tailwind CSS

12 دقائق للقراءة

Le mode sombre n’est plus une coquetterie : les utilisateurs s’attendent à ce qu’une interface respecte leur préférence système, et un tableau de bord consulté des heures durant gagne à proposer une version reposante pour les yeux. La question n’est donc plus « faut-il ? » mais « comment le faire proprement, sans dupliquer toutes ses couleurs ? ». Tailwind v4 offre deux niveaux de réponse : la variante dark: pour les cas simples, et un véritable système de thèmes par tokens pour les interfaces sérieuses. À la fin de ce tutoriel, StockLab basculera entre clair et sombre d’un clic, avec une seule source de vérité pour ses couleurs.

🎯 Ce que vous allez apprendre

  • Utiliser la variante dark: et comprendre son comportement par défaut lié au système.
  • Configurer un mode sombre contrôlé par une classe avec @custom-variant.
  • Mettre en place un interrupteur clair/sombre persistant en JavaScript, sans clignotement au chargement.
  • Bâtir un système de thèmes par tokens sémantiques avec @theme inline et des variables surchargées.

🛠️ Ce que vous allez construire

Nous ajoutons à StockLab un bouton de bascule clair/sombre dans l’en-tête. Le choix de l’utilisateur est mémorisé entre les visites, et la page se charge directement dans le bon thème sans flash blanc. En coulisses, nous remplaçons les couleurs codées en dur par des tokens sémantiques — surface, content — qui changent de valeur selon le thème.

Prérequis

  • Un projet Tailwind v4 fonctionnel avec l’interface des leçons précédentes.
  • Des notions de JavaScript pour l’interrupteur (lecture/écriture dans localStorage).
  • Test express : si vous savez ajouter un écouteur de clic sur un bouton, vous êtes prêt.
  • ⏱️ Temps estimé : environ 35 minutes.

Étape 1 — La variante dark: dans sa forme la plus simple

Par défaut, Tailwind branche le mode sombre sur la préférence système de l’utilisateur, via la requête média prefers-color-scheme. Vous n’avez rien à configurer : il suffit de préfixer une classe par dark: pour qu’elle ne s’applique qu’en mode sombre. Habillons une carte de StockLab pour qu’elle s’adapte.

<div class="rounded-lg bg-white p-6 text-gray-900 dark:bg-gray-800 dark:text-gray-100">
  <h3 class="font-semibold">Visseuse sans fil</h3>
  <p class="text-gray-500 dark:text-gray-400">37 en stock</p>
</div>

Chaque paire dit la même chose à deux voix : bg-white en clair, dark:bg-gray-800 en sombre ; text-gray-900 en clair, dark:text-gray-100 en sombre. Si votre système d’exploitation est réglé sur le thème sombre, la carte s’affiche déjà sombre. C’est immédiat et parfait pour un site vitrine. Mais une limite apparaît vite : l’utilisateur ne peut pas choisir lui-même, il subit la préférence système. Pour un tableau de bord, on veut un interrupteur.

Point d’étape — En changeant le thème de votre système d’exploitation, la carte doit basculer entre clair et sombre. Si elle ne réagit pas, vérifiez que les classes dark: sont bien présentes et que votre navigateur suit la préférence système.

Étape 2 — Passer au mode sombre piloté par une classe

Pour qu’un bouton contrôle le thème, il faut dire à Tailwind d’activer dark: non plus selon le système, mais selon la présence d’une classe sur la page. C’est le rôle de @custom-variant, ajouté à votre CSS principal juste après l’import.

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

Cette ligne redéfinit la variante dark: ainsi : « applique-toi quand un ancêtre porte la classe .dark ». Le sélecteur :where() garde une spécificité nulle pour ne pas perturber vos surcharges. Désormais, mettre class="dark" sur la balise <html> active toutes les variantes dark: de la page, et l’enlever revient au thème clair. On contrôle le thème par le DOM, exactement ce qu’il faut pour un interrupteur.

Étape 3 — L’interrupteur en JavaScript, sans clignotement

Le piège classique du mode sombre, c’est le flash blanc : la page s’affiche en clair une fraction de seconde avant que le script ne la passe en sombre. Pour l’éviter, on applique le thème avant le rendu, avec un petit script inline placé dans le <head>, qui lit le choix mémorisé.

<head>
  <script>
    if (localStorage.theme === 'dark' ||
       (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  </script>
</head>

Ce script s’exécute avant que le corps ne s’affiche : si l’utilisateur avait choisi le sombre, ou n’a rien choisi mais préfère le sombre au niveau système, la classe dark est posée immédiatement. Plus de flash. Reste à câbler le bouton de bascule, qui inverse la classe et enregistre le choix.

const bouton = document.querySelector('#theme-toggle');
bouton.addEventListener('click', () => {
  const sombre = document.documentElement.classList.toggle('dark');
  localStorage.theme = sombre ? 'dark' : 'light';
});

classList.toggle('dark') ajoute ou retire la classe et renvoie son nouvel état ; on enregistre alors dark ou light dans localStorage pour la prochaine visite. Rechargez la page après avoir choisi le sombre : elle revient en sombre, directement, sans clignoter. L’expérience est complète.

Point d’étape — Le bouton bascule le thème, le choix survit au rechargement, et aucun flash blanc n’apparaît au chargement. Si un flash subsiste, c’est que le script de thème est placé trop bas : il doit être dans le <head>, avant le contenu.

Étape 4 — Le problème de la duplication des couleurs

Notre interrupteur marche, mais une dette s’accumule : chaque élément porte une paire bg-white dark:bg-gray-800. Sur dix composants, c’est gérable ; sur cent, c’est un cauchemar de maintenance. Le jour où votre gris sombre doit changer de nuance, vous le cherchez dans tout le projet. La solution professionnelle : ne plus raisonner en couleurs concrètes mais en rôles. Une surface, un contenu, une bordure. On définit ces rôles une fois, et ils prennent la bonne valeur selon le thème.

On commence par déclarer les couleurs sémantiques en variables CSS, avec une valeur pour le clair sur :root et une valeur pour le sombre sur .dark. C’est ici, et seulement ici, que vivent les vraies couleurs.

:root {
  --surface: oklch(1 0 0);
  --content: oklch(0.21 0.02 264);
  --muted: oklch(0.55 0.02 264);
}
.dark {
  --surface: oklch(0.21 0.02 264);
  --content: oklch(0.97 0 0);
  --muted: oklch(0.71 0.02 264);
}

Quand la classe .dark est présente, ces trois variables prennent automatiquement leurs valeurs sombres. Tout ce qui les utilise suit, sans une seule classe dark: supplémentaire. Il reste à transformer ces variables en utilitaires Tailwind.

Étape 5 — Exposer les tokens comme utilitaires avec @theme inline

Pour écrire bg-surface ou text-content, il faut que Tailwind connaisse ces tokens. C’est le rôle de @theme inline. L’option inline est cruciale ici : elle fait que l’utilitaire utilise directement la valeur de la variable, ce qui permet à la surcharge sous .dark de fonctionner au moment de l’exécution.

@theme inline {
  --color-surface: var(--surface);
  --color-content: var(--content);
  --color-muted: var(--muted);
}

Tailwind génère alors les utilitaires bg-surface, text-content, text-muted, etc. Notre carte se réécrit de façon spectaculairement plus propre : <div class="bg-surface text-content">. Plus aucune paire dark: : le basculement se fait au niveau des variables, pas du markup. Pour changer la teinte de surface du mode sombre dans tout le projet, vous modifiez une seule ligne dans .dark. C’est la définition même d’un système de thèmes maintenable, et c’est ce qui distingue un projet jouet d’une application sérieuse.

Sans l’option inline, l’utilitaire référencerait la variable de thème de Tailwind plutôt que votre variable surchargeable, et le basculement échouerait silencieusement : les couleurs resteraient figées sur leur valeur claire. C’est l’erreur la plus subtile de tout ce mécanisme, et la raison pour laquelle ce mot-clé mérite qu’on s’y arrête.

Aller plus loin : des thèmes multiples

Le bel effet de bord de cette architecture, c’est qu’elle ne se limite pas à deux thèmes. Puisque tout repose sur des variables surchargées sous un sélecteur, vous pouvez ajouter un thème « contraste élevé » ou un thème de marque alternatif simplement en déclarant un nouveau bloc, par exemple [data-theme="contraste"] { … }, avec ses propres valeurs de --surface et --content. Vos composants n’ont pas à changer d’une virgule : ils consomment des rôles, pas des couleurs. C’est exactement cette indirection qui rend les grands systèmes de design gérables sur la durée, et Tailwind v4 la rend accessible avec une poignée de lignes.

Penser le contraste, pas seulement les couleurs

Un mode sombre réussi ne se résume pas à inverser le noir et le blanc. Le piège le plus courant est de réutiliser telles quelles les teintes du mode clair : un texte gris moyen, lisible sur fond blanc, devient illisible sur fond sombre, et un blanc pur sur fond noir profond fatigue l’œil par excès de contraste. La bonne pratique consiste à adoucir les deux extrêmes : on évite le noir absolu pour la surface sombre (un gris très foncé légèrement teinté est plus reposant) et le blanc absolu pour le texte (un blanc cassé réduit l’éblouissement). C’est précisément pour cela que raisonner en tokens sémantiques est si utile : vous ajustez la valeur de --content ou de --surface en un seul endroit jusqu’à obtenir un contraste confortable, sans repasser sur chaque composant.

L’accessibilité doit guider ces choix. Les recommandations d’accessibilité fixent un ratio de contraste minimal entre le texte et son fond ; un mode sombre mal calibré peut tomber sous ce seuil et exclure une partie de vos utilisateurs. Prenez l’habitude de vérifier le contraste de vos tokens à l’aide d’un outil dédié, dans les deux thèmes. L’espace colorimétrique oklch employé par Tailwind v4 aide ici : en exprimant la clarté de façon perceptuellement uniforme, il rend plus prévisible l’ajustement d’une teinte pour la rendre plus claire ou plus sombre sans dérive de couleur. Vous gagnez un contrôle fin sur la luminosité perçue, ce qui est exactement ce dont on a besoin pour équilibrer un thème sombre.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
Flash blanc au chargement avant le passage en sombre Le script de thème s’exécute trop tard Placer le script inline dans le <head>, avant le contenu
Le bouton bascule mais le choix n’est pas mémorisé Oubli d’écriture dans localStorage Enregistrer localStorage.theme à chaque clic
Les tokens bg-surface ne changent pas en sombre @theme utilisé sans l’option inline Écrire @theme inline pour que la surcharge runtime fonctionne
dark: ignore la classe .dark La variante par défaut suit le système, pas une classe Ajouter @custom-variant dark (&:where(.dark, .dark *));

✅ Récapitulatif

StockLab dispose désormais d’un mode sombre digne d’une application réelle. Vous savez utiliser la variante dark: pour les cas simples, la rebrancher sur une classe avec @custom-variant, et câbler un interrupteur persistant sans flash grâce à un script dans le <head>. Surtout, vous avez remplacé les couleurs codées en dur par des tokens sémantiques exposés via @theme inline, ce qui ramène toute votre palette à une poignée de variables surchargeables — et ouvre la porte à autant de thèmes que nécessaire.

🧾 Aide-mémoire

Élément Rôle
dark:bg-gray-800 Style appliqué en mode sombre
@custom-variant dark (&:where(.dark, .dark *)) Brancher le sombre sur une classe
localStorage.theme Mémoriser le choix de thème
Script de thème dans le <head> Éviter le flash blanc
@theme inline { --color-surface: var(--surface) } Exposer un token surchargeable en utilitaire

💪 À vous de jouer

Ajoutez un token sémantique --accent (la couleur des boutons et liens), avec une teinte vive en clair et une version légèrement adoucie en sombre, puis exposez-le en utilitaire bg-accent. Appliquez-le au bouton « Ajouter un article ».

Voir une solution
:root { --accent: oklch(0.55 0.2 264); }
.dark { --accent: oklch(0.7 0.16 264); }

@theme inline {
  --color-accent: var(--accent);
}
/* dans le HTML */
<button class="bg-accent text-white px-4 py-2 rounded-md">Ajouter</button>

Le bouton adopte la couleur d’accent appropriée à chaque thème, sans aucune classe dark: : la variable fait tout le travail.

Tutoriels frères

Ressources et références

FAQ

Q : Faut-il configurer quelque chose pour que dark: marche ?
R : Non pour le comportement par défaut, qui suit la préférence système. Pour contrôler le thème par une classe, ajoutez @custom-variant dark (&:where(.dark, .dark *));.

Q : Pourquoi un flash blanc apparaît-il au chargement ?
R : Parce que la page s’affiche avant que le script n’applique le thème. Placez un petit script qui pose la classe dark dans le <head>, avant le rendu du corps.

Q : À quoi sert exactement le mot-clé inline dans @theme inline ?
R : Il fait que l’utilitaire utilise la valeur de la variable plutôt qu’une référence à la variable de thème, ce qui permet à une surcharge sous .dark de prendre effet à l’exécution.

Q : Puis-je avoir plus de deux thèmes ?
R : Oui. Déclarez un bloc supplémentaire, par exemple sur [data-theme="contraste"], avec ses propres valeurs de variables. Les composants, qui consomment des tokens, n’ont pas à changer.

مشاركة