Créer un thème sombre avec CSS variables et JavaScript
Publié le
Le thème sombre n'est plus une option : la plupart des systèmes d'exploitation et des navigateurs proposent désormais une préférence utilisateur, et de nombreux visiteurs l'activent. Proposer un thème sombre sur son site améliore le confort de lecture, réduit la fatigue oculaire en environnement peu éclairé et économise la batterie sur les écrans OLED.
Bonne nouvelle : pas besoin de dupliquer toute votre feuille de style. Avec les variables CSS (custom properties), un peu de JavaScript et deux ou trois bonnes pratiques d'accessibilité, vous pouvez mettre en place un thème sombre propre, persistant et respectueux des préférences système.
🎨 Pourquoi utiliser des variables CSS pour un thème sombre ?
Les variables CSS permettent de définir une valeur à un endroit et de la réutiliser
partout ailleurs. Pour un thème, c'est idéal : au lieu d'écrire
color: #1a1a1a; à cinquante endroits, vous écrivez
color: var(--color-text); une seule fois. Changer de
thème revient alors à redéfinir les valeurs des variables, sans toucher au reste du CSS.
- Centralisation : toutes les couleurs sont regroupées au même endroit.
- Maintenance : une seule modification se propage partout.
- Flexibilité : on peut changer de thème à chaud, sans recharger la page.
- Performance : aucune requête supplémentaire, aucun CSS dupliqué.
🧱 Étape 1 : Définir les variables du thème clair
On commence par déclarer les variables sur le sélecteur
:root, qui correspond à l'élément
<html>. Elles seront donc disponibles partout
dans le document.
:root {
/* Thème clair (par défaut) */
--color-bg: #ffffff;
--color-surface: #f4f5f7;
--color-text: #1a1a1a;
--color-muted: #5a5a5a;
--color-accent: #d35400;
--color-border: #e2e4e8;
/* Indique au navigateur les thèmes supportés */
color-scheme: light;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
transition: background-color 0.3s ease, color 0.3s ease;
}
La propriété color-scheme est souvent oubliée :
elle indique au navigateur quel schéma de couleurs appliquer aux éléments natifs (barres
de défilement, champs de formulaire, <select>…).
Sans elle, votre thème sombre aura toujours des scrollbars blanches, ce qui casse
complètement l'effet.
🌙 Étape 2 : Redéfinir les variables pour le thème sombre
On crée ensuite une règle qui ne s'applique que lorsque l'attribut
data-theme="dark" est présent sur
<html>. On y redéfinit simplement les mêmes
variables avec des valeurs sombres.
[data-theme="dark"] {
--color-bg: #121212;
--color-surface: #1e1e1e;
--color-text: #f0f0f0;
--color-muted: #a8a8a8;
--color-accent: #ff8a3d;
--color-border: #2e2e2e;
color-scheme: dark;
}
Comme le reste du CSS utilise uniquement
var(--color-...), tout le site bascule instantanément.
Aucun autre sélecteur n'a besoin d'être modifié.
🖥️ Étape 3 : Respecter les préférences système
Un bon thème sombre ne s'impose pas : il s'adapte. La requête média
prefers-color-scheme permet de détecter ce que
l'utilisateur a choisi au niveau de son système d'exploitation.
@media (prefers-color-scheme: dark) {
/* Appliqué seulement si l'utilisateur n'a pas choisi manuellement le thème clair */
:root:not([data-theme="light"]) {
--color-bg: #121212;
--color-surface: #1e1e1e;
--color-text: #f0f0f0;
--color-muted: #a8a8a8;
--color-accent: #ff8a3d;
--color-border: #2e2e2e;
color-scheme: dark;
}
}
Le sélecteur :root:not([data-theme="light"]) est la
pièce maîtresse. Il signifie : « applique le thème sombre système,
sauf si l'utilisateur a explicitement choisi le thème clair ». Le
choix manuel gagne toujours sur la préférence système, ce qui est exactement le
comportement attendu.
🔘 Étape 4 : Créer un bouton de bascule accessible
Le bouton doit être compréhensible par tout le monde, y compris par les lecteurs
d'écran. On utilise donc un vrai <button> avec
un libellé explicite, et on fera évoluer son
aria-label et son état via JavaScript.
<button
type="button"
id="theme-toggle"
class="theme-toggle"
aria-pressed="false"
aria-label="Activer le thème sombre"
>
<span aria-hidden="true">🌙</span>
<span>Thème sombre</span>
</button>
Pourquoi aria-pressed ? Il
transforme le bouton en bouton à bascule au sens ARIA et indique s'il est actif ou non.
Un lecteur d'écran annoncera par exemple « Thème sombre, bouton bascule,
enfoncé ». C'est plus fiable que de se contenter de changer le texte visible.
⚙️ Étape 5 : Écrire le JavaScript
Le script doit répondre à trois besoins : appliquer le bon thème au chargement,
basculer entre les deux thèmes au clic, et mémoriser le choix de l'utilisateur avec
localStorage.
const root = document.documentElement;
const toggle = document.querySelector('#theme-toggle');
const STORAGE_KEY = 'theme';
/**
* Détermine le thème à appliquer :
* 1. Le choix mémorisé (localStorage)
* 2. Sinon, la préférence système
* 3. Sinon, le thème clair par défaut
*/
function getPreferredTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
/**
* Applique le thème à la page et met à jour le bouton.
*/
function applyTheme(theme) {
const isDark = theme === 'dark';
root.setAttribute('data-theme', theme);
toggle.setAttribute('aria-pressed', String(isDark));
toggle.setAttribute(
'aria-label',
isDark ? 'Activer le thème clair' : 'Activer le thème sombre'
);
}
// Application au chargement
applyTheme(getPreferredTheme());
// Bascule au clic
toggle.addEventListener('click', () => {
const current = root.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem(STORAGE_KEY, next);
});
// Suivre les changements de préférence système
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (event) => {
// On ne suit le système que si l'utilisateur n'a rien choisi manuellement
if (!localStorage.getItem(STORAGE_KEY)) {
applyTheme(event.matches ? 'dark' : 'light');
}
});
Explication du code JavaScript
-
Récupération de l'élément racine
const root = document.documentElement;
On cible directement<html>, l'élément sur lequel on pose l'attributdata-theme. -
Lecture de la préférence
localStorage.getItem(STORAGE_KEY)
Si l'utilisateur a déjà cliqué sur le bouton, on retrouve son choix. Sinon, on interroge le système viamatchMedia. -
Application du thème
root.setAttribute('data-theme', theme);
C'est cet attribut qui déclenche la règle CSS[data-theme="dark"]écrite à l'étape 2. -
Mise à jour de l'état du bouton
toggle.setAttribute('aria-pressed', String(isDark));
On synchronise l'état visuel et l'état ARIA du bouton. -
Persistance du choix
localStorage.setItem(STORAGE_KEY, next);
Le choix est conservé d'une visite à l'autre. Seules les valeurs"light"et"dark"sont stockées. -
Écoute du changement système
matchMedia(...).addEventListener('change', ...)
Si l'utilisateur change de thème au niveau du système (par exemple avec la bascule automatique au coucher du soleil), la page suit — mais uniquement s'il n'a pas fixé son choix manuellement.
⚡ Étape 6 : Éviter le flash de thème clair (FOUC)
Il reste un défaut invisible dans la démo mais très visible sur un vrai site : au chargement, le HTML s'affiche en thème clair pendant quelques millisecondes avant que le JavaScript ne s'exécute et n'applique le thème sombre. C'est le fameux flash of unstyled content.
La solution consiste à placer un petit script
inline, dans le <head>, avant le rendu du <body>.
<script>
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
Ce script s'exécute immédiatement, avant que le navigateur ne peigne la page. L'attribut
data-theme est donc déjà en place au premier
rendu : aucun flash. La fonction englobante
(function () { ... })(); évite de polluer la portée
globale.
⚠️ À noter : en plaçant ce script dans le
<head>, on introduit un léger coût de parsing
bloquant. Ici, c'est négligeable (quelques lignes), et le gain en confort visuel est
bien supérieur au coût.
🧪 Exemple interactif
Cliquez sur le bouton ci-dessous pour basculer le thème de cette petite carte. Le comportement reproduit exactement la logique décrite dans l'article.
Ma carte de démonstration
Ce contenu, sa couleur de fond et ses bordures reposent entièrement sur des
variables CSS redéfinies par l'attribut
data-theme.
✅ Bonnes pratiques à retenir
- Ne jamais se reposer uniquement sur la couleur. Un lien ne doit pas être identifié seulement par sa teinte : ajoutez un soulignement ou une graisse différente.
- Vérifier les contrastes. Le ratio minimum recommandé par les WCAG est de 4,5:1 pour le texte courant et 3:1 pour le texte large. Un thème sombre mal calibré peut être moins lisible qu'un thème clair.
-
Utiliser
color-scheme. Sans elle, les éléments natifs du navigateur resteront en clair et casseront l'illusion. -
Respecter
prefers-reduced-motion. Désactivez les transitions de couleur si l'utilisateur a demandé à limiter les animations. -
Stocker uniquement
"light"ou"dark". Évitez de stocker"auto": il est plus simple de supprimer la clé dulocalStoragepour revenir au comportement système. - Prévoir un troisième état (optionnel). Sur un vrai site, vous pouvez proposer trois choix : clair, sombre, automatique. Cela demande un cycle à trois états dans le bouton.
🧭 En résumé
Un thème sombre réussi repose sur trois piliers : des
variables CSS pour centraliser les couleurs, une
préférence système respectée via
prefers-color-scheme, et un
choix utilisateur persistant stocké dans
localStorage. Le tout tient en une cinquantaine de
lignes, sans dépendance externe.
Une fois cette base en place, vous pouvez l'enrichir : thème par section, thème par composant, voire plusieurs palettes (sépia, contraste élevé…). La logique reste exactement la même.