RGAA Lab

Composants accessibles

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

Les couleurs de ce site, mesurées dans ses deux thèmes. Les aperçus suivent le thème que vous lisez ; les deux colonnes de droite donnent les deux mesures, quel que soit ce thème.
Ce qui est peintAperçuSeuilThème clairThème sombre
Texte courant --rl-ink sur --rl-bgAa4,5:1
Texte secondaire --rl-ink-soft sur --rl-bgAa4,5:1
Lien et action --rl-action sur --rl-bgAa4,5:1
Texte sur aplat --rl-on-fill sur --rl-actionAa4,5:1
Limite d’un contrôle --rl-line-control sur --rl-surface3,0:1
Filet de séparation --rl-line sur --rl-bgaucun

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) sur html paraît gratuit, et la rotation de teinte fait sa part : le bleu d’action #1b3a8c ressort #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 #f1c27d ressort #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

ToucheEffet
TabEntre 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 / EspaceSur 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 dark sur la racine.
  • Jamais de color sans background-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-pressed avec un libellé qui ne change pas.
  • Une passe en forced-colors: active : rien ne doit disparaître.

Les critères RGAA en jeu

Vérifiez votre composant avec l'extension

Pour aller plus loin

Sources