Le thème sombre
Un thème sombre n'est pas un thème clair inversé : chaque palette se mesure pour elle-même. L'erreur courante est de ne contrôler que la claire. Démo qui mesure les deux, code, critères RGAA.
4 critères RGAA en jeu
Essayez-le
| Ce qui est peint | Aperçu | Seuil | Thème clair | Thème sombre |
|---|---|---|---|---|
Texte courant --rl-ink sur --rl-bg | Aa | 4,5:1 | — | — |
Texte secondaire --rl-ink-soft sur --rl-bg | Aa | 4,5:1 | — | — |
Lien et action --rl-action sur --rl-bg | Aa | 4,5:1 | — | — |
Texte sur aplat --rl-on-fill sur --rl-action | Aa | 4,5:1 | — | — |
Limite d’un contrôle --rl-line-control sur --rl-surface | 3,0:1 | — | — | |
Filet de séparation --rl-line sur --rl-bg | aucun | — | — |
Le filet de séparation reste sous 1,5:1 dans les deux thèmes, et ce n’est pas un manquement : il ne véhicule aucune information, le critère 3.3 ne le concerne pas. La limite d’un contrôle, juste au-dessus, porte la forme du champ — elle, doit tenir 3:1.
Ce tableau mesure les couleurs de la page que vous lisez, dans ses deux thèmes à la fois. Changez maintenant de thème avec le sélecteur de l’en-tête — au clavier : tabulez jusqu’au groupe, les flèches parcourent Auto, Clair et Sombre. Les aperçus de la deuxième colonne changent ; les deux colonnes de droite, elles, ne bougent pas. C’est exactement l’écart entre ce qu’on voit et ce qu’il faut vérifier.
Cette démo existe aussi en page autonome.
L’erreur courante
Le thème sombre écrit en une nuit : on ouvre un bloc prefers-color-scheme et
on y retourne les fonds.
:root {
--fond: #ffffff;
--texte-secondaire: #6b7280; /* 4,83:1 sur le fond clair — passe */
}
/* À ne pas faire : seul le fond est redéfini */
@media (prefers-color-scheme: dark) {
:root {
--fond: #111827;
}
} Le gris n’a pas bougé, le fond si. Le même #6b7280 qui donnait 4,83:1 sur
blanc en donne 3,67:1 sur #111827 : sous le seuil de 4,5:1, sur tout ce
que ce jeton peint — légendes, métadonnées, textes d’aide. Rien ne l’a
signalé, parce que la maquette était en clair et l’audit aussi.
C’est le défaut typique de ce composant, et il est particulier : il ne se voit pas dans le code, seulement dans la mesure — et dans la mesure de l’autre thème.
Trois variantes coûteuses :
- L’inversion globale.
filter: invert(1) hue-rotate(180deg)surhtmlparaît gratuit, et la rotation de teinte fait sa part : le bleu d’action#1b3a8cressort#a7c6ff, un bleu clair. Ce que le filtre n’a pas rendu, c’est la clarté — elle reste inversée, sur tout, images comprises. Les photographies partent donc en négatif de luminosité : un ton chair#f1c27dressort#623300, même teinte, brun sombre. Il faut alors les ré-inverser une à une pour les sauver. Et les rapports de contraste ne sont pas conservés, parce que la luminance n’est pas linéaire : le 17,92:1 de cette page devient 17,38:1 après le filtre. Le résultat n’est donc mesuré nulle part — c’est un thème sombre qu’on n’a pas écrit et qu’on ne peut pas relire. - Le blanc pur sur noir pur. 21:1, le maximum possible : le référentiel pose un plancher, pas un plafond, et ce n’est donc pas une non-conformité. C’est un choix d’inconfort — le halo, cette irradiation des lettres claires sur fond très sombre, gêne notamment les astigmates. Une encre à peine adoucie sur un fond à peine relevé garde plus de quinze points de contraste sans le halo.
- Le sélecteur muet. Un bouton qui ne porte qu’une icône de lune ne dit ni ce qu’il fait, ni où l’on en est. La couleur de la page a changé, et c’est tout ce qui l’annonce — ce qui n’annonce rien à qui ne la voit pas.
Le code, pas à pas
1. Deux palettes, chacune mesurée
:root {
color-scheme: light dark;
--fond: #fbfaf8;
--encre: #14120f; /* 17,92:1 */
--encre-douce: #57534c; /* 7,33:1 */
}
@media (prefers-color-scheme: dark) {
:root {
--fond: #141310;
--encre: #f3f0ea; /* 16,33:1 */
--encre-douce: #a8a296; /* 7,32:1 */
}
} Chaque jeton a son pendant, et chaque pendant porte son rapport mesuré. 7,33 contre 7,32 : ce sont les valeurs réelles de ce site, et l’égalité n’est pas une coïncidence — c’est ce que veut dire « conçu » plutôt qu’« inversé ». Un fond sombre ne rend pas les mêmes teintes que le blanc, il faut donc les choisir, pas les calculer par symétrie.
color-scheme: light dark prévient le navigateur que la page sait faire les
deux. Il en tire les barres de défilement, les contrôles de formulaire natifs
et le fond qu’il peint avant que la feuille de styles n’arrive.
2. Le système d’abord, le choix explicite ensuite
/* Le système, quand l'utilisateur n'a rien choisi. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
/* … le jeu sombre … */
}
}
/* Le choix explicite, quel que soit le système. */
:root[data-theme='dark'] {
/* … le même jeu sombre … */
} Les deux blocs sont nécessaires, et le garde :not([data-theme='light']) aussi : sans lui, choisir « clair » sur une machine réglée en sombre ne
changerait rien.
La valeur « suivre le système » ne s’écrit pas : c’est l’absence de data-theme qui la porte. Un sélecteur qui poserait data-theme="light" pour
dire « auto » figerait le thème de qui n’avait rien demandé.
Enfin, le choix mémorisé doit être appliqué avant le premier rendu, par un
court script dans le head. Sinon la page s’affiche un instant dans le thème
du système avant de basculer : un éclair blanc, désagréable pour tout le monde
et douloureux pour qui a réglé sombre par photophobie.
3. Le sélecteur, et ce qu’il annonce
<fieldset>
<legend class="sr-only">Thème d'affichage — actuellement Sombre</legend>
<label><input type="radio" name="theme" value="auto" /> Auto</label>
<label><input type="radio" name="theme" value="light" /> Clair</label>
<label><input type="radio" name="theme" value="dark" checked /> Sombre</label>
</fieldset> Trois états plutôt que deux, parce qu’une bascule à deux positions oblige à renoncer au suivi du système : une fois le choix figé, l’écran ne change plus au coucher du soleil.
Trois boutons radio plutôt qu’une bascule maison, parce que l’état est alors annoncé sans une ligne de script, les trois options sont visibles d’un coup, et les flèches les parcourent.
La légende porte le nom accessible du groupe, et c’est sa raison d’être : sans elle, le lecteur d’écran annonce « groupe » sans dire de quoi, et les trois libellés flottent sans objet — Auto, clair, sombre de quoi ? Elle est masquée à l’œil parce que la barre d’en-tête donne déjà ce contexte visuellement.
Le suffixe « — actuellement Sombre » est une redondance assumée, pas une nécessité : Tab entre dans le groupe sur l’option cochée, qui est annoncée avec son état et sa position. L’état est donc déjà exposé sans lui. Il sert à qui explore le groupe autrement qu’en tabulant — au curseur virtuel, par exemple, où la légende s’entend avant les cases. Le retirer ne coûte aucune conformité.
Si vous tenez à la bascule à deux états :
<button type="button" aria-pressed="false">Thème sombre</button> aria-pressed porte l’état, donc le libellé ne bouge pas. Un libellé qui
change (« Passer en thème clair ») en même temps qu’un aria-pressed qui
change produit l’annonce contradictoire « Passer en thème clair, bouton
bascule, activé » : le libellé nomme l’action à venir, l’attribut nomme l’état
courant, et les deux se lisent d’affilée. C’est cette contradiction qui est
interdite, pas la redondance — la légende du groupe radio, elle, répète l’état
que les cases annoncent déjà, sans jamais le démentir.
4. Aucune couleur déclarée seule
/* À ne pas faire : la moitié d'une paire */
.encart {
color: #14120f;
} Le critère 10.5 demande que toute déclaration de couleur de police, sur un élément susceptible de contenir du texte, s’accompagne d’une couleur de fond — sur l’élément ou héritée d’un parent — et réciproquement. Un thème sombre est précisément ce qui transforme cette règle d’hygiène en panne visible : le jour où le fond change ailleurs, cet encart garde son encre noire et devient illisible.
Le contrôle porte sur les déclarations, pas sur le rendu : il se fait dans la feuille de styles, pas sur une capture d’écran.
5. Le mode contraste élevé de Windows
@media (forced-colors: active) {
.pastille {
/* Le système repeint fonds et textes : sans trait, la forme disparaît. */
border: 1px solid;
}
} En mode contraste forcé, le système impose son propre couple de couleurs et ignore les vôtres. Ce qui casse, ce sont les formes dessinées par un fond seul — une pastille, un interrupteur, une barre de graphique : elles se retrouvent peintes de la même couleur que ce qui les entoure. Un simple trait les sauve.
Les mots-clés système (Canvas, CanvasText, LinkText, ButtonBorder, Highlight) permettent de suivre ce que le système a choisi plutôt que de le
combattre. forced-color-adjust: none conserve vos couleurs, et ne se justifie
que là où elles portent l’information — un nuancier, une carte : vous en
redevenez alors comptable.
Aucun critère du RGAA n’exige la prise en charge de forced-colors : c’est une
bonne pratique, pas une obligation. Elle se vérifie en une minute, et les
utilisateurs concernés sont souvent ceux-là mêmes que le thème sombre visait.
6. Avez-vous besoin d’un sélecteur ?
prefers-color-scheme seul répond déjà à qui a réglé son système. Un sélecteur
se justifie quand les conditions de lecture ne suivent pas ce réglage : un
rapport lu en plein soleil sur une machine en sombre, une photophobie sur un
poste partagé.
Si vous en posez un, il doit conserver les contenus et les fonctionnalités à l’identique. C’est la définition même du mécanisme qui permet d’afficher un rapport de contraste conforme : un thème qui masque des contenus n’est plus un thème, c’est une autre page.
Au clavier
| Touche | Effet |
|---|---|
| Tab | Entre dans le groupe de choix, sur l’option cochée — un seul arrêt pour les trois. |
| ← → ↑ ↓ | Coche l’option suivante ou précédente, y déplace le focus, et applique le thème aussitôt. |
| Tab (à nouveau) | Sort du groupe. Rien n’y reste « à valider ». |
| Entrée / Espace | Sur une bascule à deux états : inverse le thème, le focus reste sur le bouton. |
Le comportement des flèches n’est pas à écrire : c’est celui d’un groupe de
boutons radio de même name, et le navigateur le donne.
Ce que le lecteur d’écran restitue
Sur le groupe de choix : le nom du groupe, l’état courant, puis l’option — de l’ordre de « Thème d’affichage, actuellement Sombre, groupe, Sombre, bouton radio coché, 3 sur 3 ». Sur une bascule à deux états : « Thème sombre, bouton bascule, activé ». La formulation varie d’un lecteur à l’autre, les informations non.
Si vous n’entendez que « Sombre, bouton », l’état n’est pas exposé : ni aria-pressed, ni bouton radio — une icône de lune ne dit rien. Si vous
entendez « Passer en thème clair, bouton bascule, activé », le libellé et
l’état se contredisent, et l’un des deux est en trop.
Et rien n’est annoncé quand les couleurs changent : c’est normal. C’est le contrôle qui porte l’information, pas la page.
Checklist
- Deux palettes déclarées, chaque couleur ayant son pendant dans l’autre thème.
- Chaque paire mesurée deux fois : 4,5:1 pour le texte, 3:1 à partir de 24px (18,5px en gras) et pour les composants.
color-scheme: light darksur la racine.- Jamais de
colorsansbackground-color, ni l’inverse. - Le système suivi par défaut, le choix explicite prioritaire, mémorisé et appliqué avant le premier rendu.
- Le sélecteur annonce son état : trois boutons radio, ou
aria-pressedavec un libellé qui ne change pas. - Une passe en
forced-colors: active: rien ne doit disparaître.
Les critères RGAA en jeu
- Critère 3.2 — Contraste du texte
Deux thèmes, deux mesures : un texte qui passe 4,5:1 sur fond clair peut tomber sous le seuil dès que le fond change, et rien dans le code ne le signale.
- Critère 3.3 — Contraste des composants d’interface
Bordure de champ, icône, anneau de focus : ce qui donne sa forme à un composant se remesure dans le thème sombre, où un gris moyen conçu pour du blanc s'effondre.
- Critère 10.5 — Déclarations CSS de couleurs
Un thème sombre qui ne redéfinit que les fonds laisse des couleurs de texte déclarées seules ; le critère demande que chaque déclaration ait son pendant, sur l'élément ou héritée d'un parent.
- Critère 7.1 — Compatibilité des scripts
Le sélecteur de thème est un composant piloté par script : le thème actif doit être exposé comme un état, ce qu'une icône de lune ne fait pas.
Vérifiez votre composant avec l'extension
Pour aller plus loin
- Critère 3.2 — Contraste du texte et 3.3 — Contraste des composants d’interface : les deux mesures à refaire dans chaque thème.
- Critère 10.5 — Déclarations CSS de couleurs : la règle qui se lit dans la feuille de styles, et que le thème sombre rend visible.
- « Contraste » au glossaire : la formule que la démo applique, telle que le référentiel la publie.
- Les cases à cocher et les boutons radio : le sélecteur de thème en est un groupe, avec tout ce que cela implique.
Sources
- ARIA APG — Button Pattern — la bascule à deux états et
aria-pressed - MDN —
prefers-color-scheme— la préférence système, côté CSS - MDN —
forced-colors— ce que le mode contraste élevé impose à la page - WCAG — Understanding 1.4.3 Contrast (Minimum) — le seuil du texte, et son pourquoi
- WCAG — Understanding 1.4.11 Non-text Contrast — le seuil des composants et des éléments graphiques