prefers-color-scheme : bien l’utiliser en CSS
prefers-color-scheme est la fonctionnalité média CSS qui signale le schéma
de couleurs demandé par l’utilisateur — light ou dark — tel qu’il est défini
dans le système d’exploitation ou le navigateur. Bien l’utiliser signifie s’en
servir uniquement comme valeur par défaut : des propriétés personnalisées
définissent le thème clair sur :root, la media query inverse ces valeurs par
défaut quand le système préfère le sombre, et un attribut data-theme posé par
un bouton manuel surcharge les deux. La media query seule n’est pas une
implémentation, car la plupart des produits réels ont aussi besoin du bouton —
et un bouton ne peut pas surcharger une media query depuis le CSS.
Ceci est la référence d’implémentation pour la mécanique de bascule de notre guide du mode sombre : la query, le motif de surcharge, et les quatre pièges qui remplissent les forums d’entraide.
Que lit réellement la media query ?
La préférence exprimée par l’utilisateur, transmise depuis l’environnement : le réglage d’apparence du système d’exploitation (ou une surcharge au niveau du navigateur là où elle existe) apparaît en CSS comme une fonctionnalité média, exactement comme la largeur du viewport. La forme la plus simple :
@media (prefers-color-scheme: dark) {
/* styles pour les utilisateurs qui ont demandé le sombre */
}
Aucun JavaScript n’est en jeu, et la query est vivante : modifiez le réglage du système et les styles correspondants s’appliquent immédiatement. Les valeurs de la fonctionnalité et les notes de compatibilité sont sur la page de référence de MDN. Ce que la query ne connaît pas, c’est votre interface — elle rapporte l’état du système, pas le bouton dans votre en-tête.
Pourquoi un bouton manuel doit-il surcharger la media query ?
Parce que tout produit réel finit avec trois états, pas deux : suivre le système, clair forcé et sombre forcé. Un utilisateur dont le système est en sombre peut malgré tout vouloir votre application en clair (les longues sessions de lecture en sont une raison courante), et inversement. La media query ne peut exprimer que le premier état — le motif robuste superpose donc un attribut par-dessus :
:root {
color-scheme: light dark;
--background: oklch(0.94 0.008 262.9);
--text-primary: oklch(0.28 0.02 262.9);
}
/* par défaut : suivre le système */
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.22 0.015 262.9);
--text-primary: oklch(0.94 0.008 262.9);
}
}
/* états forcés : l'attribut l'emporte sur la valeur par défaut */
[data-theme="light"] {
color-scheme: light;
--background: oklch(0.94 0.008 262.9);
--text-primary: oklch(0.28 0.02 262.9);
}
[data-theme="dark"] {
color-scheme: dark;
--background: oklch(0.22 0.015 262.9);
--text-primary: oklch(0.94 0.008 262.9);
}
body { background: var(--background); color: var(--text-primary); }
Lisez-le comme une cascade de valeurs par défaut : :root dit clair, la media
query dit « sauf si le système préfère le sombre », l’attribut dit « sauf si
l’utilisateur nous a dit autrement ». Sans attribut, le site suit le système ;
avec un attribut, le choix in-app de l’utilisateur l’emporte. Côté JavaScript,
c’est une ligne plus le stockage :
const saved = localStorage.getItem("theme"); // "light" | "dark" | null
if (saved) document.documentElement.dataset.theme = saved;
null — le troisième état — signifie « ne poser aucun attribut et laisser la
media query décider ». Stocker explicitement "system" et retirer l’attribut
fonctionne de la même manière.
Voilà l’exemple complet : environ vingt-cinq lignes de CSS et trois de JavaScript couvrent le suivi du système, les deux états forcés et les contrôles de formulaire natifs. Les valeurs elles-mêmes vont par paires — une par thème, par rôle — et c’est là que la couche de tokens fournit ses données au motif.
Voir un ensemble de rôles portant les valeurs des deux thèmes dans Scale Composer — chaque rôle sémantique avec sa valeur claire et sa valeur sombre côte à côte, prêtes à coller dans les deux blocs ci-dessus.

Quels sont les pièges qui remplissent les forums d’entraide ?
Les contrôles de formulaire ignorent votre thème. Votre CSS peint la page en
sombre, mais les barres de défilement, cases à cocher et menus déroulants restent
clairs — parce que c’est le navigateur qui les peint, et on ne lui a rien dit.
Le correctif est la ligne déjà présente dans le motif ci-dessus :
color-scheme: light dark indique au navigateur quels schémas la page prend en
charge, et les blocs d’état forcé le restreignent pour que les contrôles natifs
suivent la surcharge, pas seulement la query.
Le flash du mauvais thème. Un utilisateur qui préfère le sombre charge la
page, voit un flash blanc, puis le thème s’applique. Cela arrive quand la
surcharge stockée est appliquée par un script qui s’exécute après le premier
rendu. Le correctif honnête est un script inline dans le <head> — avant le
rendu dépendant de la feuille de style, ni différé, ni intégré au bundle — qui
lit localStorage et pose l’attribut. Il bloque le rendu par conception ;
limitez-le à ces quelques lignes.
Les images et les intégrations ne suivent pas. Un élément <picture> peut
changer de source selon le thème
avec media="(prefers-color-scheme: dark)", mais cela suit le système, pas
votre surcharge data-theme — une asymétrie connue du motif à attribut. Les
iframes résolvent la query dans leur propre contexte. Pour un contenu qui doit
suivre le bouton in-app, c’est au CSS (ou à un petit script) de faire le
changement.
La bascule anime tout. Un transition: all général sur les éléments
concernés par le thème transforme le basculement en un lent fondu enchaîné de
toutes les couleurs à l’écran. Ciblez les transitions sur les propriétés qui en
ont besoin, ou désactivez brièvement les transitions pendant le changement.
La query rend-elle le thème sombre réussi ?
Non — elle répond seulement à quel thème afficher. Que le thème sombre vaille la peine d’y basculer se décide par la palette derrière les propriétés personnalisées : un jeu sombre dérivé avec sa propre courbe de luminosité et un renfort de chroma se lit comme un thème conçu, tandis que des valeurs claires inversées se lisent comme le négatif d’un thème. La query fait le routage ; les valeurs sont le produit. Dériver ces valeurs est le sujet du reste de ce hub.
Vérifiez le motif avec vos propres valeurs
Les deux blocs du motif ne valent que ce que valent les paires dont vous les remplissez. Chargez une palette et vérifiez l’ancre de chaque rôle sous les deux thèmes — les mêmes noms de rôles se résolvant par thème, les planchers de contraste revérifiés de chaque côté, pour que les valeurs que vous collez dans la media query et dans le bloc d’attribut soient déjà des paires vérifiées.