Changer le thème

Mode sombre Tailwind : class vs data-theme, deux approches comparées

Easton editorial illustration: three-option fit selector

Cette ligne qui clignote à l’écran : dark:bg-gray-900. La question revient sans cesse : pour le mode sombre Tailwind, faut-il utiliser class ou data-theme ?

J’ai passé pas mal de temps sur ces deux approches. Chaque recherche dans la doc ne donnait que des fragments ; l’ensemble restait flou. Finalement, j’ai parcouru la doc officielle, les discussions GitHub et le code de plusieurs bibliothèques populaires. Cet article rassemble les pièges rencontrés et les arbitrages que j’en ai tirés.


Les trois stratégies de mode sombre Tailwind

Commençons par un point : Tailwind propose trois stratégies, pas deux.

Stratégie media : suivre le système automatiquement

La stratégie media est le réglage par défaut de Tailwind — beaucoup l’ignorent. Elle s’appuie sur la requête média CSS prefers-color-scheme pour détecter la préférence système.

<!-- Aucune config requise, réponse automatique au système -->
&lt;div class="bg-white dark:bg-gray-900"&gt;
  Le contenu bascule selon les réglages système
&lt;/div&gt;

Avantage évident : zéro configuration, l’utilisateur obtient un affichage cohérent avec ses habitudes. Inconvénient tout aussi net — pas de choix manuel. En environnement clair, ceux qui préfèrent le mode sombre sont mal servis.

Stratégie class : contrôle manuel

La stratégie class ajoute la classe .dark sur un élément parent (souvent &lt;html&gt;) pour activer le mode sombre. Le développeur reprend la main : bascule utilisateur, persistance des préférences.

<!-- Contrôle via JavaScript sur le nom de classe -->
&lt;html class="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    Mode sombre actif
  &lt;/body&gt;
&lt;/html&gt;

C’est l’approche la plus répandue. Documentation abondante, intégration fluide avec de nombreuses bibliothèques tierces.

Stratégie data-theme : sélecteur d’attribut sémantique

La stratégie data-theme utilise l’attribut data-theme="dark" plutôt qu’une classe. Plus claire sémantiquement, et naturellement extensible à plusieurs thèmes.

&lt;html data-theme="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    Mode sombre actif
  &lt;/body&gt;
&lt;/html&gt;

Étendre à d’autres thèmes est trivial — data-theme="oled" ou data-theme="sepia", à vous de définir. Très pratique quand il faut plusieurs modes d’affichage.


Stratégie class en détail

Principe de fonctionnement

Le principe est simple : lorsqu’une classe .dark est présente sur un ancêtre du DOM, tous les styles dark:* s’appliquent.

Sous Tailwind v3, on l’active dans la configuration :

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

La structure du sélecteur CSS généré ressemble à ceci :

.dark .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v4 adopte une configuration CSS-first avec la directive @custom-variant :

/* global.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));

Notez le pseudo-classe :where() — elle ramène la spécificité à zéro, sans perturber la cascade. Détail important.

Logique de bascule en JavaScript

Pour permettre à l’utilisateur de changer de thème, un court script suffit :

// Obtenir le thème actuel
function getTheme() {
  return localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}

// Définir le thème
function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.classList.toggle('dark', theme === 'dark');
}

// Initialisation
setTheme(getTheme());

Trois actions : lire la préférence dans localStorage, suivre le système si absent, basculer et sauvegarder. Suffisant pour la plupart des cas.

Éviter le flash blanc

Le flash blanc au chargement — je suis tombé dedans aussi. Avant l’exécution du JavaScript, le HTML s’affiche déjà en mode clair par défaut.

La solution : un script synchrone dans le &lt;head&gt;, avant le rendu du DOM :

&lt;head&gt;
  &lt;script&gt;
    // Exécution synchrone pour éviter le flash
    if (localStorage.theme === 'dark' ||
        (!('theme' in localStorage) &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  &lt;/script&gt;
&lt;/head&gt;

Ce script doit être synchrone — ni defer ni async.

Avantages et inconvénients

Avantages :

  • Implémentation simple et intuitive
  • Écosystème riche, solutions matures pour chaque framework
  • Bonne intégration avec next-themes et outils similaires
  • Spécificité légèrement plus élevée, couverture des styles assurée

Inconvénients :

  • La classe .dark manque de clarté sémantique — il faut réfléchir pour comprendre qu’il s’agit du mode sombre
  • Extension multi-thèmes via plusieurs classes, gestion plus lourde
  • Adaptation supplémentaire avec les variables CSS

Stratégie data-theme en détail

Principe de fonctionnement

La stratégie data-theme repose sur un sélecteur d’attribut, pas de classe. Configuration Tailwind v4 :

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

Sélecteur CSS généré :

[data-theme='dark'] .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v3 le supporte aussi, avec une configuration en tableau :

// tailwind.config.js
module.exports = {
  darkMode: ['selector', '[data-theme="dark"]'],
}

Combinaison avec les variables CSS

Franchement, data-theme et les variables CSS forment un duo naturel. Vous pouvez définir des valeurs différentes sous chaque data-theme :

/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222 84% 5%;
}

[data-theme='dark'] {
  --background: 222 84% 5%;
  --foreground: 210 40% 98%;
}

[data-theme='oled'] {
  --background: 0 0% 0%;  /* Noir pur */
  --foreground: 0 0% 100%;
}

Puis référencer ces variables dans Tailwind :

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
      }
    }
  }
}

En changeant l’attribut data-theme, tous les styles basés sur ces variables basculent — sans dark: sur chaque composant. Expérience bien plus confortable.

Retour d’expérience shadcn/ui

shadcn/ui adopte par défaut data-theme + variables CSS. Dans ses fichiers de styles, on trouve de nombreuses définitions de ce type :

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    /* ... autres variables */
  }

  .dark,
  [data-theme='dark'] {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... autres variables */
  }
}

Intéressant : il prend en charge à la fois .dark et [data-theme='dark'] — pour s’adapter aux habitudes. Avec shadcn/ui, l’une ou l’autre méthode convient.

Extension multi-thèmes

Le principal atout de data-theme : le multi-thèmes. Mode OLED, mode confort visuel — simple à définir :

&lt;html data-theme="oled"&gt;
  <!-- Fond noir pur, adapté aux écrans OLED -->
&lt;/html&gt;

&lt;html data-theme="sepia"&gt;
  <!-- Fond jaune pâle, adapté à la lecture -->
&lt;/html&gt;

La logique de bascule se réduit à modifier l’attribut :

function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.dataset.theme = theme;
}

Cette flexibilité est difficile à reproduire avec la stratégie class.

Avantages et inconvénients

Avantages :

  • Sémantique claire — data-theme="dark" se comprend immédiatement
  • Extension multi-thèmes native
  • Intégration fluide avec les variables CSS
  • Compatible par défaut avec shadcn/ui, daisyUI, etc.

Inconvénients :

  • Configuration selector manuelle sous Tailwind v3
  • Adaptation parfois nécessaire pour certaines bibliothèques tierces
  • Documentation communautaire encore moins fournie — la situation s’améliore

Matrice de comparaison

Voici un tableau récapitulatif des dimensions clés :

Faible
Complexité impl. class
Configuration simple
Moyenne
Complexité impl. data-theme
Comprendre les sélecteurs d’attribut
Élevée
Support communautaire class
Documentation abondante
Moyenne
Support communautaire data-theme
En cours de généralisation
Difficile
Extension multi-thèmes class
Plusieurs classes requises
Facile
Extension multi-thèmes data-theme
Changer la valeur d’attribut suffit
Source: Analyse comparative des approches
DimensionStratégie classStratégie data-theme
Complexité d’implémentationFaible, configuration simpleMoyenne, sélecteurs d’attribut à maîtriser
Clarté sémantiqueMoyenne, .dark demande réflexionÉlevée, data-theme explicite
Extension multi-thèmesDifficile, plusieurs classesFacile, modifier l’attribut suffit
Support communautaireÉlevé, documentation richeMoyen, en cours de généralisation
Intégration variables CSSAdaptation supplémentaireNaturellement adaptée
Tailwind v3darkMode: 'class'darkMode: ['selector', '...']
Tailwind v4@custom-variant@custom-variant
Compatibilité bibliothèquesVérifier au cas par casshadcn/ui et similaires compatibles nativement
SpécificitéLégèrement plus élevée (sélecteur de classe)Identique (sélecteur d’attribut)

Quand choisir la stratégie class ?

  • Projet simple, clair et sombre uniquement
  • Stack Next.js + next-themes
  • Équipe familière de la config Tailwind v3
  • Besoin de nombreux exemples communautaires

Quand choisir la stratégie data-theme ?

  • Plusieurs thèmes (OLED, confort visuel, etc.)
  • Utilisation de shadcn/ui ou bibliothèque similaire
  • Intégration profonde avec les variables CSS
  • Exigence élevée en sémantique

Intégration framework en pratique

Intégration Astro

L’intégration Astro + Tailwind est simple, avec un piège : les View Transitions.

Configuration de base :

// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()]
  }
});

Script mode sombre :

&lt;!-- Dans le head de BaseLayout.astro --&gt;
&lt;script is:inline&gt;
  // Script synchrone anti-flash
  const theme = localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

  if (theme === 'dark') {
    document.documentElement.classList.add('dark');
    // Ou avec data-theme
    // document.documentElement.dataset.theme = 'dark';
  }
&lt;/script&gt;

Gestion des View Transitions :

Les View Transitions d’Astro re-rendent le DOM au changement de page ; l’état du mode sombre se perd facilement. Écoutez astro:after-swap pour réappliquer le thème :

&lt;script&gt;
  document.addEventListener('astro:after-swap', () => {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  });
&lt;/script&gt;

Étape cruciale — beaucoup l’oublient, moi y compris au début.

Intégration Next.js + next-themes

Pour Next.js, next-themes est recommandé. Il encapsule toute la logique de bascule, compatibilité SSR et hydratation incluses.

Installation :

npm install next-themes

Configuration du Provider :

// components/ThemeProvider.tsx
import { ThemeProvider } from 'next-themes';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    &lt;ThemeProvider
      attribute="class"        // Stratégie class
      defaultTheme="system"    // Suivre le système par défaut
      enableSystem={true}      // Détection système activée
      disableTransitionOnChange  // Éviter le flash à la bascule
    &gt;
      {children}
    &lt;/ThemeProvider&gt;
  );
}

Pour passer à data-theme, modifiez attribute :

&lt;ThemeProvider attribute="data-theme" defaultTheme="system"&gt;

Utilisation dans le layout :

// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';

export default function RootLayout({ children }) {
  return (
    &lt;html lang="zh"&gt;
      &lt;body&gt;
        &lt;ThemeProvider&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}

Composant de bascule :

// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-lg"
    &gt;
      {theme === 'dark' ? '☀️' : '🌙'}
    &lt;/button&gt;
  );
}

next-themes gère localStorage, la détection système et l’hydratation. Tranquille.


Nouveautés Tailwind v4

Tailwind v4 introduit une configuration CSS-first ; le mode sombre évolue aussi.

Directive @custom-variant

Les variants autrefois définis en JavaScript se déclarent directement en CSS :

@import 'tailwindcss';

/* Stratégie class */
@custom-variant dark (&:where(.dark, .dark *));

/* Stratégie data-theme */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

Plus intuitif — plus besoin de reconstruire le JavaScript pour modifier la config.

Directive @theme pour les variables

Avec data-theme, définissez les variables de thème via @theme :

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

@theme {
  --color-primary: oklch(0.65 0.2 150);
  --color-muted: oklch(0.9 0.02 200);
}

/* Surcharge en mode sombre */
[data-theme='dark'] {
  --color-primary: oklch(0.7 0.15 180);
  --color-muted: oklch(0.3 0.02 200);
}

Utilisation directe :

&lt;button class="bg-primary text-white"&gt;Bouton&lt;/button&gt;

Après bascule de data-theme, les couleurs suivent — sans styles redondants du type dark:bg-primary-dark.

Bascule à trois états

Pour light/dark/system, combinez avec l’API window.matchMedia :

function setTheme(theme) {
  if (theme === 'system') {
    localStorage.removeItem('theme');
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
  } else {
    localStorage.setItem('theme', theme);
    document.documentElement.dataset.theme = theme;
  }
}

// Écouter les changements de préférence système
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
    }
  });

L’utilisateur peut choisir un thème fixe ou suivre le système en permanence.


Synthèse des bonnes pratiques

Choix recommandé

Pour la plupart des projets :

  1. Projet simple : stratégie class + script de bascule minimal
  2. Avec shadcn/ui : data-theme + variables CSS directement
  3. Multi-thèmes requis : data-theme obligatoire
  4. Projet Next.js : next-themes, attribute selon le besoin
  5. Projet Astro : ne pas négliger les View Transitions

Astuces pratiques

Solution complète anti-flash :

&lt;head&gt;
  &lt;script is:inline&gt;
    // Script synchrone, avant le rendu
    (function() {
      const theme = localStorage.getItem('theme');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

      if (theme === 'dark' || (!theme && systemDark)) {
        document.documentElement.classList.add('dark');
        // Ou
        document.documentElement.dataset.theme = 'dark';
      }
    })();
  &lt;/script&gt;
&lt;/head&gt;

Projets SSR :

Next.js et autres frameworks SSR exigent d’éviter le hydration mismatch. next-themes le gère. En implémentation manuelle :

// useEffect pour éviter le mismatch SSR
import { useEffect, useState } from 'react';

function useTheme() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    setTheme(saved || 'light');
  }, []);

  return theme;
}

Nommage sémantique des variables CSS :

Préférez des noms sémantiques aux noms de couleur :

/* Recommandé */
:root {
  --background: ...;
  --foreground: ...;
  --primary: ...;
  --muted: ...;
}

/* Déconseillé */
:root {
  --white: ...;
  --black: ...;
  --gray-900: ...;
}

Le nommage sémantique rend la bascule de thème plus lisible et facilite l’ajout de nouveaux thèmes.


Conclusion

En résumé : la stratégie class est simple et mature, adaptée à la majorité des projets ; data-theme est plus sémantique, mieux adaptée au multi-thèmes et à l’intégration profonde des variables CSS.

La directive @custom-variant de Tailwind v4 simplifie la configuration des deux approches. Le choix dépend de vos besoins — avec shadcn/ui, data-theme est plus naturel ; pour une bascule clair/sombre simple, class reste fiable.

Ne négligez pas les pièges d’intégration : View Transitions sous Astro, hydratation SSR sous Next.js. Mal gérés, l’expérience en souffre.



Références

FAQ

Quelle différence entre @custom-variant de Tailwind v4 et la config v3 ?
La différence principale est l'emplacement de la configuration. En v3, on la définit dans tailwind.config.js ; en v4, on la déclare dans le CSS avec @custom-variant. Fonctionnellement identique ; la v4 suit mieux l'approche CSS-first.
Peut-on utiliser class et data-theme en même temps ?
Oui, mais ce n'est pas nécessaire. Les deux font la même chose ; les combiner ajoute de la complexité. shadcn/ui prend en charge .dark et [data-theme='dark'] pour s'adapter aux habitudes des utilisateurs — choisissez-en une seule.
Trop de modificateurs dark:, le code devient verbeux — que faire ?
Adoptez les variables CSS. Une fois définies, changer la valeur de l'attribut met à jour tous les styles qui les utilisent, sans dark: sur chaque élément.

Méthode :
1. Définir les variables avec @theme dans globals.css
2. Les surcharger sous différents [data-theme]
3. Les référencer dans tailwind.config.js

Ainsi bg-primary s'adapte automatiquement au changement de thème.
Comment éviter la perte d'état du mode sombre dans un projet Astro ?
Les View Transitions d'Astro re-rendent le DOM au changement de page, ce qui efface l'état du thème. Écoutez astro:after-swap pour le réappliquer :

document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});

Beaucoup de développeurs oublient cette étape.
Comment éviter le flash blanc au chargement ?
Placez un script synchrone dans le &lt;head&gt;, avant le rendu du DOM :

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

Le script doit être synchrone — pas defer ni async.

10 min de lecture · Publié le: 28 mars 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog