Accueil / Articles / Neuf techniques de mode sombre comparées, des astuces avec les filtres aux cookies serveur

Neuf techniques de mode sombre comparées, des astuces avec les filtres aux cookies serveur

Comparez neuf méthodes pour ajouter un mode sombre à une application web, allant des filtres inversés aux jetons, en passant par light-dark() et les cookies serveur, et découvrez quels bugs chacune d’elles introduit discrètement.

5596 mots

Le mode sombre est souvent présenté comme un compromis entre une solution rapide et la véritable solution, mais les sites en production utilisent au moins neuf techniques distinctes, chacune ne résolvant qu’une partie du problème. Les aspects ignorés par une technique apparaissent plus tard sous forme de pages qui clignotent, de en-têtes fixes endommagés ou de couleurs qui refusent silencieusement de changer. Ce guide classe ces neuf approches, explique ce qu’elles font bien et mal, puis aborde les détails qui compliquent même les implémentations minutieuses : l’apparition inopportune du mauvais thème, la propriété color-scheme, les préférences à trois états, les transitions, le contenu intégré et la conception de la palette.

Les affirmations comportementales ci-dessous sont fondées sur des documents primaires : les projets du groupe de travail CSS, la norme HTML WHATWG, les données browser-compat-data lisibles par machine utilisées sur MDN, les données de statut de base ainsi que le code source de la bibliothèque next-themes, tous vérifiés en comparaison avec Chromium sans interface graphique. Deux croyances répandues ne résistent pas à un tel examen, et un comportement spécifique — le filter qui affecte les descendants ayant une propriété position: fixed — s’avère être une raison bien plus valable d’éviter la technique d’inversion que l’argument vague lié aux performances habituellement avancé.

Le mode sombre comporte trois problèmes distincts

« Ajouter un thème sombre » semble correspondre à une seule tâche. En pratique, cela englobe trois questions suffisamment indépendantes pour que vous puissiez y répondre parfaitement et finissez tout de même par livrer un produit défectueux :

  1. Quel thème doit être affiché ? Le système d’exploitation a une préférence, l’utilisateur peut vouloir la modifier, et il a également besoin d’un moyen de revenir à la configuration par défaut du système. Un simple interrupteur allumé/éteint élimine définitivement cette troisième option.
  2. Comment les couleurs changent-elles ? Un seul interrupteur doit mettre à jour toutes les surfaces, bordures, icônes et ombres, et son emplacement définit en fait l’architecture CSS.
  3. Quand le thème est appliqué ? Si la décision est prise après le premier rendu, les utilisateurs voient la page changer de couleur sous leurs yeux.

Toute technique présentée ci-dessous répond à une partie de ces questions. Le schéma est révélateur : les approches dites « lazy » gèrent généralement uniquement la deuxième question de manière isolée, en ignorant complètement la première et la troisième.

Comment interpréter le classement

Les trois premières solutions ne sont pas des concurrentes. Elles se combinent en une seule configuration : les tokens sémantiques constituent la base, light-dark() est un moyen plus compact d’écrire ces tokens, et un cookie lisible par le serveur permet de délivrer le thème choisi sans utilisation de Flash. Les positions quatre à six représentent de véritables compromis entre lesquels il faut choisir. Les positions sept à neuf correspondent à une dette technique.

Position 9 : inverser toute la page avec filter

Le mode sombre le plus simple applique une inversion ainsi qu’une rotation des teintes à l’élément racine :

html {
  filter: invert(1) hue-rotate(180deg);
}

Cela oblige immédiatement à une deuxième règle qui inverse à nouveau chaque élément multimédia afin que les photos et vidéos reprennent leur apparence normale :

/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
  filter: invert(1) hue-rotate(180deg);
}

La principale objection soulevée concerne les performances, ce qui est difficile à démontrer de manière claire. Il en existe une bien plus importante. La documentation de MDN sur les blocs conteneurs l’explique clairement : un filter défini sur une valeur autre que none transforme l’élément en bloc conteneur pour les descendants ayant position: fixed et position: absolute. Cela crée également un nouveau contexte de superposition, ce qui modifie silencieusement la manière dont chaque z-index en dessous est calculé.

Un test sans interface rend cela concret. Placez une barre fixe à l’intérieur d’un conteneur filtré sur une page de 3000 px de hauteur dans Chromium 141, faites défiler vers le bas de 400 px et mesurez l’emplacement de la barre :

await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.

La barre indique un décalage de la zone de visualisation de 100 au lieu de 0, ce qui signifie qu’elle n’est plus fixe ; elle défile avec le contenu. Lorsqu’il est appliqué à html, ce filtre détruit donc tous les en-têtes collants, les navigations fixes, les superpositions modales, les panneaux de notification et les tiroirs présents sur la page. Il s’agit d’un défaut de correction que l’on peut reproduire en quelques lignes de code, et non d’une question de goût.

Les problèmes restants sont familiers :

  • Chaque élément graphique nécessite une inversion inverse, et les logos contenant des couleurs de marque intégrées restent incorrects, car une rotation de 180 degrés de la teinte n’est pas une inversion précise dans aucun espace de couleur.
  • Les couleurs de marque se transforment en leurs opposés mathématiques au lieu de former une palette sombre conçue exprès.
  • Aucune directive officielle ne le recommande. Les conseils de web.dev concernant le mode sombre ne suggèrent jamais d’inverser des pages entières ; pour les médias, ils proposent filter: grayscale(50%) pour les photographies et invert(100%) uniquement pour les icônes en noir et blanc.
  • Les conseils propres à Chrome concernant son inversion automatique consistent à créer un thème sombre soigneusement conçu plutôt que de désactiver cette fonctionnalité.
  • Rangs 8 et 7 : des approches qui fonctionnent jusqu’à ce qu’elles ne le fassent plus

    Défauts par composant

    Rédiger une version sombre pour chaque composant constitue la première tentative naturelle. Ce n’est pas vraiment une erreur, mais plutôt une approche sans limites. On commence par des styles clairs :

    .card       { background: #fff; color: #14161a; }
    .card .btn  { background: #f0f2f5; }
    

    puis on ajoute leurs équivalents sombres sous une classe body :

    body.dark .card            { background: #121212; color: #fff; }
    body.dark .card .btn       { background: #333; }
    body.dark .card .btn:hover { background: #444; }
    

    Puisque les règles sombres doivent l’emporter sur celles claires, les sélecteurs deviennent de plus en plus spécifiques ; body.dark .card .btn:hover comporte déjà quatre éléments dès le premier jour. Les valeurs hexadécimales varient également, avec #121212 dans un fichier, #111 dans un autre et #0f0f0f dans un composant copié, sans aucun endroit central pour vérifier automatiquement le contraste. Le problème fondamental de l’échelle se résume en une phrase : les surcharges augmentent avec le nombre de composants, tandis que les tokens augmentent avec le nombre de rôles, et la plupart des systèmes de conception se situent autour de 12 à 20 rôles.

    Deux feuilles de style distinctes

    Charger une feuille de style claire et une feuille de style sombre via des attributs media semble efficace :

    <link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
    <link rel="stylesheet" href="dark.css"  media="(prefers-color-scheme: dark)">
    

    Cela n’évite pas le deuxième téléchargement, qui est là que les gens se trompent. Comme l’article de web.dev sur prefers-color-scheme l’explique, le feuille de style dont la requête média ne correspond pas est néanmoins chargé, mais avec la priorité la plus basse, de sorte qu’il ne peut pas concurrencer les ressources dont la page a actuellement besoin. L’avantage est un chemin critique plus court, et non moins de données. De plus, l’attribut media ne lit que la configuration du système d’exploitation, de sorte qu’aucun réglage manuel ne peut l’influencer ; les deux fichiers ont tendance à s’éloigner l’un de l’autre avec le temps ; et les outils de bundling peuvent mal gérer ce couple, comme l’atteste un problème rapporté sur Vite.

    Rang 6 : un commutateur en CSS pur avec :has()

    Une case à cocher visuellement cachée et son étiquette peuvent servir de commutateur :

    <input type="checkbox" id="theme" class="sr-only">
    <label for="theme">Dark mode</label>
    

    La racine réagit alors à l’état de la case à cocher grâce à :has():

    html:has(#theme:checked) {
      color-scheme: dark;
      --bg-surface: #1b1f27;
      --text-1:     #e8e6e3;
      --border:     #2b313c;
    }
    

    Cela ne nécessite vraiment pas de JavaScript, et :has() a atteint le niveau de disponibilité « largement disponible » le 19 juin 2026. Deux points sont importants. Premièrement, il faut modifier les tokens de couleur ainsi que color-scheme, comme le montre l’exemple. Deuxièmement, l’état n’existe que dans le DOM, de sorte que chaque chargement de page commence à zéro et rien n’est enregistré. De plus, il est impossible d’exprimer proprement trois états ; cela nécessiterait des boutons radio et davantage de branches de sélecteur. Cela convient bien pour une démo, un CodePen ou un document unique, mais pas pour un produit.

    Rang 5 : la variante dark: de Tailwind

    La variante dark: n’est pas fausse, elle place simplement les décisions de couleur au mauvais endroit : dans les templates, répétées à chaque point d’appel.

    <div class="bg-white dark:bg-zinc-900
                text-zinc-900 dark:text-zinc-100
                border-zinc-200 dark:border-zinc-800
                hover:bg-zinc-50 dark:hover:bg-zinc-800">
    

    Le résultat sont de longues chaînes de classes qui s’éparpillent, ce qui rend impossible toute vérification par un script, car les valeurs sombres sont dispersées dans les templates. De plus, l’ajout d’un thème comme celui à fort contraste ou d’une « skin » de marque ne fait qu’augmenter le nombre de balises au lieu d’en ajouter une seule couche. Cela ne sert à rien pour l’interface graphique générée par le navigateur et nécessite encore un script séparant pour éviter des clignotements.

    La solution se trouve au sein même de Tailwind. Associez les couleurs des thèmes à des variables CSS et modifiez ces variables une seule fois ; vous pourrez alors utiliser bg-surface sans aucun préfixe dark:. Commencez par l’import standard :

    @import "tailwindcss";
    

    Ensuite, définissez une variante personnalisée, reliez les couleurs des thèmes aux variables correspondantes et attribuez à chaque thème ses propres valeurs de variable :

    @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
      --color-surface: var(--surface);
      --color-content: var(--content);
    }:root               { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
    [data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
    [data-theme="hc"]   { --surface: #000000; --content: #ffffff; color-scheme: dark; }
    

    L’encadrement :where() est intentionnel. Il ne confère aucune spécificité supplémentaire à la variante sombre, de sorte que les outils dark: ne suppriment jamais par erreur des styles non liés. Ajouter un thème à fort contraste ne coûte qu’une ligne de code plutôt que de devoir traiter chaque template.

    Rang 4 : suivre uniquement prefers-color-scheme

    L’option la plus simple et fiable consiste à définir par défaut des tokens pour l’affichage clair, puis à les redéfinir à l’intérieur d’une requête média :

    :root {
      color-scheme: light;
      --bg-base: #ffffff;  --text-1: #14161a;
    }
    
    @media (prefers-color-scheme: dark) {
      :root {
        color-scheme: dark;
        --bg-base: #12141a;  --text-1: #e8e6e3;
      }
    }
    

    Il n’y a ni JavaScript, ni effets visuels dynamiques, et rien à charger dynamiquement. C’est l’approche la plus rapide de toute la liste, et son seul inconvénient est décisif pour les applications mais sans importance pour le contenu : les utilisateurs ne peuvent pas modifier la configuration système.

    Si personne n’a jamais demandé de fonctionnalité d’activation/désactivation, comme c’est courant pour les blogs, la documentation, les journaux de modifications et les pages marketing, arrêtez-vous ici. Aucune autre option dans cette liste n’est supérieure pour ce type de site.

    Deux détails concernant les spécifications méritent d’être connus :

    • Media Queries Niveau 5 avertit que cette fonctionnalité pourrait acquérir davantage de valeurs à l’avenir, le sépia étant donné comme exemple, et recommande de tester par négation : (prefers-color-scheme: dark) contre (not (prefers-color-scheme: dark)), plutôt que de correspondre explicitement à light.
    • La valeur no-preference a été supprimée. Elle fait défaut dans la spécification actuelle et aucun navigateur ne l’implémente ; un utilisateur sans préférence correspond à light.

    Rang 3 : light-dark() et son mode d’échec silencieux

    light-dark() réduit approximativement de moitié la taille d’un fichier de tokens, car une seule déclaration contient les deux valeurs :

    :root { color-scheme: light dark; }   /* REQUIRED */
    
    .card {
      background:   #fff;                          /* fallback for old browsers */
      background:   light-dark(#fff, #1b1f27);
      color:        light-dark(#14161a, #e8e6e3);
      border-color: light-dark(#e2e5ea, #2b313c);
    }
    /* a manual override becomes ONE property write */
    [data-theme="dark"]  { color-scheme: dark; }
    [data-theme="light"] { color-scheme: light; }
    

    Il s’agit d’un système de thèmes complet, sans aucun bloc @media ni deuxième règle :root. La modification manuelle se limite à définir color-scheme sur le niveau racine.

    Cependant, cela coûte des heures aux équipes. En exécutant plusieurs variantes sous Chromium 141 avec le schéma de couleurs forcé des deux manières, trois constats importants émergent :

    1. Sans color-scheme, light-dark() ne fait rien. Sur un système sombre, il restitue silencieusement les couleurs claires, et rien dans la console ne trahit le problème. C’est la faille la plus fréquente de light-dark(), qui reste invisible jusqu’à ce que quelqu’un sur un système sombre la signale.
  • Déclarer color-scheme: dark sur un élément le fait choisir le deuxième argument, quel que soit ce que dit le système d’exploitation. C’est pourquoi un mécanisme de basculement manuel consiste en une simple modification d’une propriété plutôt qu’en un changement de classe accompagné d’un ensemble de règles parallèles.
  • Configurer color-scheme: dark sur un élément autre que la racine ne lui donne pas d’arrière-plan sombre. Cela modifie les couleurs du système et les contrôles natifs, mais l’arrière-plan du canvas suit uniquement celui de la racine. C’est le malentendu le plus répandu concernant cette propriété.
  • Comme vérification pour votre propre palette, en mode sombre Chromium calcule la couleur système Canvas en rgb(18, 18, 18), ce qui correspond à #121212, même valeur de base que suggère Material pour les thèmes sombres.

    • Il s’agit d’une valeur de référence « récemment disponible » et non « largement disponible » : Chrome et Edge 123, Firefox 120 et Safari 17.5, avec une date de disponibilité récente du 13/05/2024, ce qui place le seuil de disponibilité large vers le 13/11/2026.
    • Elle ne gère pas correctement les cas de dégradation. Les navigateurs qui ne la prennent pas en charge rejettent toute la déclaration comme invalide, il faut donc toujours placer d’abord un fallback simple pour la même propriété, comme l’exemple le montre.
    • C’est une valeur de couleur, elle ne peut donc pas être utilisée comme condition dans une requête média. L’utiliser à l’intérieur de déclarations au sein d’un bloc @media est acceptable ; ce sont deux choses distinctes.
    • Les arguments d’image, tels que dans light-dark(url(a.png), url(b.png)), n’ont été ajoutés que dans Chrome 150, Firefox 150 et Safari 27 selon les données de compatibilité à l’époque de rédaction, qui sont trop récentes pour être fiables.

    Avertissement concernant la documentation : la page descriptive de light-dark() sur MDN cite Chrome 119 et Safari 17.2, tandis que les données browser-compat-data fournies par MDN ainsi que l’API de référence indiquent respectivement 123 et 17.5. Lorsque ces sources diffèrent, il faut se fier aux données structurées et vérifier les pages actuelles avant de citer des versions spécifiques.

    Rang 2 : les tokens sémantiques, la couche dont tout le reste a besoin

    La règle fondamentale est de nommer le rôle joué par une couleur, et non la couleur elle-même. Commencez par les valeurs primitives, c’est-à-dire les valeurs de palette brutes que les composants ne consultent jamais directement :

    /* primitives: raw values, never consumed by components */
    :root {
      --gray-0: #ffffff;  --gray-50: #f4f5f7;  --gray-200: #e2e5ea;
      --gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
      --blue-500: #3b82f6; --blue-400: #60a5fa;
    }
    

    Par-dessus celles-ci se trouve une couche sémantique. Les valeurs claires constituent la valeur par défaut ; un seul bloc [data-theme="dark"] remplace toutes les autres, et les composants ne prennent en compte que les noms de rôle, ce qui leur permet de ne pas avoir besoin de savoir quel thème est actif :

    /* semantic roles: light is the default */
    :root {
      color-scheme: light;
      --bg-base:    var(--gray-0);
      --bg-surface: var(--gray-50);
      --text-1:     var(--gray-900);
      --text-2:     var(--gray-600);
      --border:     var(--gray-200);
      --accent:     var(--blue-500);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.08);
    }/* one block flips the whole app */
    [data-theme="dark"] {
      color-scheme: dark;
      --bg-base:    #12141a;   /* grey, not #000 */
      --bg-surface: #1b1f27;   /* lighter = higher up */
      --bg-raised:  #232833;   /* lighter still */
      --text-1:     #e8e6e3;
      --text-2:     #a2acbb;
      --border:     #2b313c;
      --accent:     var(--blue-400);
      --shadow-sm:  0 1px 2px rgb(0 0 0 / 0.5);
    }/* components never know which theme is active */
    .card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
    

    Prêtez attention aux détails dans le bloc sombre : la base est gris foncé plutôt que noire, les surfaces s’éclaircissent à mesure qu’elles se trouvent plus haut, l’accent passe à une teinte encore plus claire, et l’ombre devient plus marquée pour rester visible.

    Un test simple permet de déterminer si le nom d’un token est approprié : pouvez-vous en décrire l’usage sans mentionner de couleur ? « Arrière-plan d’un panneau surélevé » décrit un rôle ; « gris clair » décrit une nuance de couleur. Seuls les rôles survivent au changement de thème, car un token nommé littéralement « gris clair » ne devrait jamais devenir sombre.

    Pour le switch lui-même, l’attribut data-theme est préférable à la classe .dark. Il peut naturellement contenir trois ou plus de valeurs, ne risque pas de entrer en conflit avec les classes utilitaires, et sa modification se fait par une simple affectation à document.documentElement.dataset.theme. Pour en savoir plus sur l’utilisation de propriétés personnalisées en temps de exécution, consultez le guide du blog sur l’utilisation de propriétés CSS personnalisées pour la personnalisation des thèmes.

    Rang 1 : lecture d’un cookie de thème sur le serveur

    L’affichage du thème sur le serveur est la seule option qui permet d’éviter les quatre coûts habituels : un script en ligne de commande, une animation soudaine, un désaccord lors de l’hydratation et une exception de la politique de sécurité du contenu, car le serveur connaît déjà le thème avant d’envoyer le premier octet. Dans un projet Next.js App Router, le layout racine importe l’outil pour gérer les cookies :

    // app/layout.tsx
    import { cookies } from 'next/headers';
    

    et écrit le thème enregistré directement sur l’élément html, en recourant au thème clair par défaut :

    export default async function RootLayout({ children }) {
      const store = await cookies();                 // async since Next 15
      const theme = store.get('theme')?.value ?? 'light';  return (
        <html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
          <body>{children}</body>
        </html>
      );
    }
    

    Le changement de thème s’effectue via une action serveur, qui nécessite la même importation :

    // app/actions.ts
    'use server';
    import { cookies } from 'next/headers';
    

    Cette action enregistre le choix choisi pour une durée d’un an, avec une portée valable pour tout le site :

    export async function setTheme(theme: 'light' | 'dark') {
      const store = await cookies();
      store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
    }
    

    Les coûts associés sont documentés par Next.js lui-même :

    • cookies() est une API à exécuter au moment de la demande ; l’appel de cette fonction dans un layout ou une page fait basculer ce routage vers un rendu dynamique, ce qui empêche le pré-rendu statique.
    • Avec les composants en cache activés, l’appel de cookies() en dehors d’une balise <Suspense> empêche également le pré-rendu.
    • HTTP ne permet pas de définir des cookies une fois le streaming ayant commencé ; par conséquent, le cookie doit être écrit à l’aide de .set dans une fonction serveur ou un gestionnaire de route, et jamais pendant le rendu.
  • Chaque requête contient le cookie, ce qui ajoute environ 15 octets.
  • Les paramètres du système d’exploitation sont invisibles au serveur, il faut donc toujours résoudre le choix system du côté du client. La combinaison pratique consiste à utiliser le cookie pour les choix explicites et matchMedia dans le cas du système.
  • Il existe une indication côté client, Sec-CH-Prefers-Color-Scheme, qui permettrait de communiquer la préférence du système d’exploitation au serveur. Il s’agit seulement d’un projet préliminaire du WICG, disponible uniquement dans Chromium et ne constituant pas une norme ; il faut donc le considérer au mieux comme une optimisation, et non comme une solution universelle.

    Empêcher l’affichage du thème incorrect

    C’est le troisième problème rencontré depuis le début, et c’est aussi le défaut le plus courant lié au mode sombre sur les sites mis en ligne. Le débat sur « la bonne méthode par rapport à la méthode négligente » ne l’évoque généralement pas du tout. Le calendrier montre pourquoi un script différé est trop tardif :

    DEFERRED SCRIPT:  [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK]   <- user sees it
    BLOCKING INLINE:  [HTML][JS][CSS][PAINT: DARK]                   <- correct first paint
    

    Le thème doit être défini sur html avant la première mise en page. La solution consiste à ajouter un petit script inline dans head, placé avant le fichier de style :

    <head>
      <meta charset="utf-8">
      <meta name="color-scheme" content="light dark">
      <script>
        // inline. no src, no defer, no async, no type=module.
        (function () {
          try {
            var s = localStorage.getItem('theme');   // 'light'|'dark'|'system'|null
            var dark = s === 'dark' ||
              ((!s || s === 'system') &&
               matchMedia('(prefers-color-scheme: dark)').matches);
            var el = document.documentElement;
            el.dataset.theme     = dark ? 'dark' : 'light';
            el.style.colorScheme = dark ? 'dark' : 'light';
          } catch (e) { /* storage throws in private mode / sandboxed iframes */ }
        })();
      </script>
      <link rel="stylesheet" href="/app.css">
    </head>
    

    Toute contrainte dans cet extrait est importante. Le script doit être en ligne et synchrone, sans attributs src, defer, async ni être de type module, car n’importe lequel de ces éléments permettrait au navigateur d’afficher le contenu en premier. Il lit une préférence à trois valeurs et détermine la valeur system via matchMedia. Il définit à la fois l’attribut correspondant et colorScheme, afin que les éléments affichés et l’interface utilisateur soient en accord. De plus, il encadre l’accès au stockage dans des blocs try/catch, car localStorage génère des erreurs dans les modes de navigation privée ainsi que dans les iframes isolés.

    Le véritable coût réside dans la politique de sécurité : un script en ligne nécessite l’option 'unsafe-inline' ou l’utilisation d’un nonce dans votre politique CSP. Si aucune de ces options n’est autorisée, il convient d’utiliser l’approche basée sur les cookies.

    Dans Next.js, vous avez également besoin de suppressHydrationWarning sur l’élément html, car le script modifie ses attributs avant que React ne procède à l’hydratation, et ceux-ci ne correspondent plus au markup serveur. Comme le précise la documentation de next-themes, ce paramètre n’a d’effet qu’à un niveau, il ne cache donc pas les avertissements d’hydratation ailleurs.

    En examinant le code source de next-themes, on constate à quel point sa prévention des effets de flash est rudimentaire. Il affiche un <script dangerouslySetInnerHTML> dont le contenu est sa propre fonction script(), convertie en chaîne de caractères à l’aide de script.toString() et appelée immédiatement avec des arguments sérialisés en JSON. Son hook useTheme() renvoie theme, setTheme, resolvedTheme, systemTheme et themes ; or theme vaut undefined lors du rendu serveur. Affichez votre bouton de commutation à partir de resolvedTheme après une vérification d’installation, sinon le bouton lui-même provoquera une erreur de hydration.

    color-scheme : la propriété que la plupart des sites n’configurent jamais

    Votre feuille de style définit la couleur de ce que vous avez écrit, mais le navigateur lui-même dessine les barres de défilement, les contrôles de formulaire natifs ainsi que le canevas en arrière-plan de la page. La spécification CSS Color Adjustment exige que l’agent utilisateur adapte tous ces éléments au schéma de couleur de l’élément :

    • les couleurs par défaut des barres de défilement et de l’interface utilisateur interactive
    • l’apparence par défaut des contrôles de formulaire
    • les éléments supplémentaires de l’interface du navigateur, comme les soulignements de correction orthographique
    • les couleurs système telles que Canvas, CanvasText, ButtonFace, Field et AccentColor
    • le résultat de la fonction light-dark()

    Sur l’élément racine, le schéma contrôle en plus la couleur de la surface du tableau de dessin ainsi que les barres de défilement de la vue. Il doit être déclaré en trois endroits. Tout d’abord, une balise meta que le parseur HTML lit avant l’arrivée de tout CSS :

    <!-- parsed at HTML-parse time, BEFORE any CSS loads -->
    <meta name="color-scheme" content="light dark">
    

    Ensuite, des règles CSS qui maintiennent cette propriété en synchronisation avec l’attribut theme :

    :root               { color-scheme: light dark; }
    [data-theme="dark"] { color-scheme: dark; }
    [data-theme="light"]{ color-scheme: light; }
    

    Enfin, le cas échéant, un mécanisme de verrouillage qui assure que un widget spécifique reste clair quel que soit le thème :

    /* force a widget to stay light regardless */
    .brand-widget       { color-scheme: only light; }
    

    La balise meta n’est pas redondante. La section du standard HTML consacrée à la meta pour le thème de couleur existe précisément afin que le navigateur puisse peindre l’arrière-plan de la page avec le bon thème immédiatement, sans attendre les feuilles de style. La propriété CSS n’est connue qu’après que la feuille de style ait été téléchargée et analysée ; cette période d’attente provoque un écran blanc temporaire. Le standard autorise également au plus un tel élément meta par document.

    Deux autres pièges :

    • La propriété et la requête média ne sont pas liées. Déclarer color-scheme: dark ne fait jamais en sorte que prefers-color-scheme: dark s’applique, de sorte que tout code qui tente d’en déduire l’autre fonctionnera incorrectement.
  • Le mot-clé only indique au navigateur qu’il ne doit pas modifier le schéma de l’élément. En pratique, c’est ainsi qu’une page empêche Chrome sur Android d’appliquer son thème sombre automatique. Son historique de compatibilité est étrange : ajouté dans Chrome 81, supprimé en 85 puis réintroduit en 98.
  • Trois états au lieu d’une valeur booléenne

    Dès que la fonction devient booléenne, l’option « suivre mon système d’exploitation » disparaît et l’utilisateur ne peut plus la récupérer. Cette préférence nécessite trois valeurs : clair, sombre et système. Commencez par une clé de stockage et une liste de requêtes médias :

    const STORAGE_KEY = 'theme';
    const mq = matchMedia('(prefers-color-scheme: dark)');
    

    Le reste de la logique applique cette préférence, la sauvegarde, la lit à nouveau en utilisant system comme valeur par défaut, et continue d’observer les changements du système d’exploitation uniquement tant que system est sélectionné :

    function apply(pref) {                     // 'light' | 'dark' | 'system'
      const dark = pref === 'dark' || (pref === 'system' && mq.matches);
      const el = document.documentElement;
      el.dataset.theme     = dark ? 'dark' : 'light';
      el.style.colorScheme = dark ? 'dark' : 'light';
    }function setPreference(pref) {
      try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
      apply(pref);
    }function getPreference() {
      try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
      catch (e) { return 'system'; }
    }// keep following the OS, but ONLY while 'system' is the chosen preference
    mq.addEventListener('change', () => {
      if (getPreference() === 'system') apply('system');
    });apply(getPreference());
    

    Utilisez addEventListener sur MediaQueryList, et non addListener. MediaQueryList hérite désormais de EventTarget, et addListener ainsi que removeListener sont dépréciés, bien que de nombreux exemples en ligne les utilisent encore. next-themes conserve délibérément ces méthodes dépréciées, avec un commentaire source l’indiquant, afin de prendre en charge les anciennes versions de Safari.

    Arrêter le flou de couleur lors du changement

    Si les éléments de la page présentent des transitions de couleur, le changement de thème anime des centaines de propriétés en même temps, ce qui provoque un flou visible sur la page. La solution utilisée par next-themes dans son option disableAnimation injecte un style temporaire qui désactive toutes les transitions :

    function disableTransitionsTemporarily(nonce) {
      const css = document.createElement('style');
      if (nonce) css.setAttribute('nonce', nonce);
      css.appendChild(document.createTextNode(
        `*,*::before,*::after{ transition: none !important }`
      ));
      document.head.appendChild(css);
    

    Celui-ci renvoie une fonction qui restaure les transitions, et un outil swapTheme gère le changement de thème entre les deux états :

      return () => {
        // Deliberate forced synchronous reflow: commit the new colours
        // WHILE transitions are still off.
        (() => window.getComputedStyle(document.body))();
        // Remove on a later task, after the flush has committed.
        setTimeout(() => { document.head.removeChild(css); }, 1);
      };
    }function swapTheme(next) {
      const enable = disableTransitionsTemporarily();
      apply(next);
      enable();
    }
    

    L’appel à getComputedStyle semble être du code inactif et est souvent supprimé. Il s’agit en réalité d’un nettoyage forcé et synchronisé des styles, permettant d’appliquer les nouvelles couleurs tant que les transitions sont encore désactivées. La suppression est ensuite reportée à une tâche ultérieure via setTimeout, une fois le nettoyage effectif. Sans ce réajustement forcé ni cette suppression différée, on observe toujours un fondu partiel.

    Si vous préférez que le changement soit visible, l’API View Transitions prend en charge l’effet de révélation circulaire familier. Elle est désormais disponible dans la version Baseline depuis le 14/10/2025, avec Chrome 111, Safari 18 et Firefox 144. Une exigence facile à négliger : désactiver les animations par défaut sur les captures racines et définir le mode de fusion sur l’ancienne capture :

    ::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
    ::view-transition-new(root) { animation: none; }
    

    Respectez également les utilisateurs qui souhaitent moins de mouvements :

    @media (prefers-reduced-motion) {
      ::view-transition-group(*),
      ::view-transition-old(*),
      ::view-transition-new(*) { animation: none !important; }
    }
    

    Ne supprimez pas mix-blend-mode: normal de l’ancienne capture. Avec le mode de fusion par défaut plus-lighter, l’écran devient brièvement d’un blanc laiteux au cours du passage d’une couleur sombre à une claire, ce qui ressemble à un bug de flash et est effectivement signalé comme tel.

    Qu’est-ce qui continue de poser des problèmes après le changement de tokens

    Images et SVG

    <picture> avec media="(prefers-color-scheme: dark)" suit uniquement la configuration du système d’exploitation. Il ne peut pas prendre en compte votre attribut theme ni votre color-scheme ; par conséquent, un changement manuel met les images en décalage avec le reste de l’interface, ce qui constitue une erreur courante présente dans les versions publiées. Les images d’arrière-plan déclarées à l’intérieur de votre bloc correspondant au thème sombre suivent bien ce changement. Pour les SVG, currentColor fonctionne dans des <svg> en ligne ainsi que dans des <svg><use href="…">, mais pas pour les SVG chargés via <img src> ou CSS url(), car il s’agit de documents distincts qui n’héritent jamais de votre color. Vous devez soit placer les requêtes prefers-color-scheme directement dans le fichier SVG, soit utiliser mask-image en combinaison avec background-color: currentColor.

    Iframes

    La spécification relative à l’ajustement des couleurs stipule que lorsque le schéma de couleurs d’un iframe diffère de celui de la racine du document intégré, le navigateur doit dessiner une toile opaque dans la propriété Canvas du document intégré plutôt qu’une toile transparente. En pratique, cela crée un rectangle blanc sur une page sombre. Assigner une valeur à color-scheme pour l’élément <iframe> corrige ce problème concernant la première toile, mais le CSS propre au document d’une autre origine reste inaccessible. YouTube, Stripe Elements, Disqus et Turnstile proposent chacun une option de thème distincte ; il n’existe pas de solution au niveau du CSS.

    theme-color

    Le support de la métadonnée theme-color est bien plus faible que ce à quoi on pourrait s’attendre. Firefox ne le prend pas en charge sur aucune plateforme ; Chrome pour ordinateur, à partir de la version 73, l’applique uniquement aux PWA installés ; Safari l’a adopté dans sa version 15, mais à partir de Safari 26, il ne le respecte que dans les applications web installées. MDN indique qu’il dispose d’une disponibilité limitée, et non du statut de référence. La spécification permet également aux navigateurs d’ajuster la couleur à leur guise, par exemple en l’assombrissant pour maintenir un contraste adéquat, il ne faut donc pas compter sur une rendu exact.

    Couleurs forcées et préférences de contraste

    Dans le mode Haut contraste de Windows (forced-colors: active), le navigateur prend le contrôle des propriétés telles que background-color, color, border-color, outline-color, text-decoration-color ainsi que les propriétés SVG fill et stroke. Les propriétés box-shadow et text-shadow sont réinitialisées à none, et color-scheme est fixé sur light dark. Toute élévation visuelle dépendante des ombres disparaît, il faut donc la remplacer par des bordures aux couleurs du système :

    @media (forced-colors: active) {
      /* your shadow-based elevation is gone — replace it */
      .card { border: 1px solid CanvasText; box-shadow: none; }
      .btn  { border: 1px solid ButtonText; }
    }
    

    La couleur système attribuée à un élément dépend de sa sémantique HTML native, et non de son rôle ARIA ; ainsi, un <div role="button"> ne reçoit pas la propriété ButtonText. MDN insiste sur le fait qu’il ne faut pas créer un design distinct pour les couleurs forçées, mais seulement apporter de légères modifications pour améliorer la lisibilité.

    Un autre axe est souvent complètement oublié : prefers-contrast, avec les valeurs no-preference, more, less et custom. Baseline est largement disponible depuis le 31/05/2022 et indépendant du thème de couleur. Les thèmes sombres qui ne prennent jamais en compte la valeur more représentent une lacune fréquente en matière d’accessibilité.

    Concevoir la palette sombre

    Utilisez du gris foncé, pas du noir

    Les directives de Material préfèrent le gris foncé au noir pour les arrière-plans et les surfaces sombres, car le gris permet de garder les ombres visibles et réduit la fatigue oculaire avec du texte clair. Le codelab sur les thèmes sombres de Google mentionne également l’importance de la couleur d’arrière-plan : du texte en #FFFFFF pur sur un arrière-plan sombre peut sembler se diluer ou devenir flou, ce qui nuit à la lisibilité.

    Formulez cela avec prudence. L’idée souvent répétée selon laquelle le noir pur provoquerait un effet de « halo » ne semble pas être étayée par la moindre étude contrôlée. Ce qui est confirmé, c’est l’effet de transparence et de vibration du texte blanc pur, ainsi que le choix du gris plutôt que du noir justifié par la visibilité des ombres et la fatigue oculaire.

    Exprimez l’élégance par la luminosité

    Les ombres fonctionnent mal sur les thèmes sombres, c’est pourquoi Material compense en rendant les surfaces plus claires et légèrement plus colorées à mesure qu’elles s’éloignent. La fonction color-mix() permet d’obtenir facilement ces effets à partir d’une seule couleur de base :

    [data-theme="dark"] {
      --surface-1: #12141a;
      --surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
      --surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
      --surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
    }
    

    color-mix() est largement disponible depuis la version Baseline le 09-11-2025. Gardez à l’esprit que le système d’overlay d’élevation de Material Design 2 est obsolète : la documentation de Google indique que ces overlays ont été remplacés par le système de couleur de surface tonale et ne sont plus maintenus. Material 3 utilise des rôles allant de surfaceContainerLowest à surfaceContainerHighest, ainsi que surfaceDim et surfaceBright. Une référence qui mentionne l’ancienne échelle allant de 5 % à 1 dp jusqu’à 16 % à 24 dp cite donc un système déprécié.

    Désaturer les accents

    Des accents de tons moyens saturés vibrent sur des surfaces sombres. OKLCH rend cet ajustement systématique : son canal de luminosité est perçue comme uniforme, de sorte que les valeurs numériques pairs donnent également un aspect régulier, ce qui est précisément là où HSL échoue et explique pourquoi les rampes de couleur sombres en HSL deviennent terne au milieu. Un accent plus clair et moins chromatique pour le thème sombre se présente comme ceci :

    :root               { --accent: oklch(0.55 0.18 255); }  /* darker tone on light bg */
    [data-theme="dark"] { --accent: oklch(0.72 0.14 255); }  /* lighter, less chroma */
    

    Vérifier à nouveau le contraste pour le thème sombre

    Une palette qui respecte les critères de contraste en mode clair ne vous renseigne en rien sur le mode sombre. La formule de luminosité relative de WCAG 2.x est suffisamment courte pour être conservée dans un script :

    const lum = ([r, g, b]) => {
      const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
      return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
    };
    

    Le rapport de contraste divise alors la luminosité plus claire par celle qui est plus sombre, chaque valeur étant décalée de 0,05 :

    const contrast = (a, b) => {
      const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
      return (hi + 0.05) / (lo + 0.05);
    };
    

    En appliquant cette vérification à chaque couleur utilisée pour la lecture de texte, on détecte de manière fiable les problèmes que l’œil pourrait manquer. Prenons par exemple un thème de couleur papier chaude où tout semblait correct à l’écran : une teinte ambre présentait un rapport de contraste de 2,06:1, une teinte rouge utilisée pour l’highlighting syntaxique un rapport de 2,80:1, et une teinte or utilisée pour les chiffres un rapport de 2,50:1. Ces trois couleurs avaient toutes été approuvées visuellement, mais aucune ne respecte les critères AA.

    La formule de WCAG 2.x présente un point faible connu : elle traite de la même manière les contrastes clair sur foncé et foncé sur clair, ce qui signifie qu’une palette sombre peut obtenir de bons scores tout en étant encore trop contrastée. C’est cette faiblesse qui a conduit au développement d’APCA, mais APCA ne fait pas partie de WCAG 2.2 et n’est pas encore normative nulle part.

    Que disent les recherches sur le mode sombre et la lisibilité

    Cet aspect est souvent présenté de manière inversée. Le résumé des recherches du Nielsen Norman Group indique :

    • Pour les personnes ayant une vision normale, le mode clair a montré de meilleurs résultats dans toutes les mesures menées par Piepenbrock et al. (2013, Ergonomics) ainsi que Dobres et al. (2017, Applied Ergonomics). L’avantage s’accroît à mesure que la taille du texte diminue. L’explication est optique : du texte foncé sur un fond clair produit plus de lumière, la pupille se contracte, et une pupille plus petite entraîne moins d’aberrations sphériques ainsi qu’une plus grande profondeur de champ.
    • Pour les personnes ayant une vision faible, Legge et al. (1985, Vision Research) ont constaté que les sept participants présentant des troubles du milieu oculaire, notamment des cataractes, lisaient plus rapidement en mode sombre.
    • À long terme, Aleman et al. (2018, Scientific Reports) suggèrent que l’exposition prolongée au mode clair pourrait être liée à la myopie en raison de l’affinage de la choroïde.
    • La recommandation pratique est de permettre aux utilisateurs de passer en mode sombre s’ils le souhaitent.

    En réalité, le mode sombre répond à des préférences personnelles et à certains besoins d’accessibilité ; il ne constitue pas une amélioration avérée de la lisibilité pour le grand public. C’est là l’argument le plus solide en faveur d’un contrôle à trois états plutôt que de définir par défaut le mode sombre.

    Points clés

    • Considérez le mode sombre comme relevant de trois problèmes : choisir le thème, modifier les couleurs et appliquer ce choix avant la première affichage.
    • Nommez les éléments en fonction de leur rôle, inversez-les dans un seul bloc data-theme, et maintenez color-scheme au niveau racine en synchronisation avec lui, soutenu par la balise meta.
    • Décidez du thème avant la première affichage, soit à l’aide d’un cookie lu par le serveur, soit avec un script en ligne bloquant, et acceptez les compromis liés au CSP que ce script implique.
    • Proposez trois états : clair, sombre et système, et suivez les changements du système uniquement lorsque ce dernier est sélectionné.
  • Si personne n’a besoin d’un interrupteur, utiliser prefers-color-scheme plutôt qu’une couche de tokens est la solution la plus rapide et la plus simple.
  • N’utilisez jamais filter: invert() sur une page que vous contrôlez : cela fait du conteneur racine le bloc contenant pour chaque élément fixe.
  • Vérifiez à nouveau le contraste, les images, les iframes ainsi que le comportement des couleurs forçées pour le thème sombre ; inverser les tokens ne les corrige pas automatiquement.
  • Lectures complémentaires