Implémentez un dark mode CSS natif avec prefers-color-scheme, variables CSS et localStorage : guide complet avec exemples pratiques.
Introduction — pourquoi le dark mode ?
Le dark mode (ou mode sombre) est passé en quelques années du statut d'anecdote pour développeurs à une fonctionnalité attendue par les utilisateurs. iOS 13, Android 10, Windows 10 et macOS Mojave ont tous introduit ce mode système en 2018-2019, entraînant une adoption massive. Aujourd'hui, selon les statistiques, plus de 82 % des utilisateurs de smartphones activent le dark mode au moins occasionnellement.
Au-delà de l'esthétique, le mode sombre apporte des bénéfices concrets :
- Réduction de la fatigue oculaire en environnement peu éclairé
- Économie de batterie sur les écrans OLED/AMOLED (jusqu'à 63 % selon Google)
- Meilleure lisibilité pour les personnes sensibles à la lumière vive
- Aspect professionnel — une interface soignée qui respecte les préférences utilisateur
La bonne nouvelle : CSS moderne offre une solution élégante et native via la
media query prefers-color-scheme, combinée aux variables CSS
(custom properties). Plus besoin de dupliquer vos feuilles de style — une seule base
de code suffit pour gérer les deux thèmes.
- Détecter la préférence système avec
prefers-color-scheme - Créer un système de design tokens via les variables CSS
- Implémenter un toggle manuel avec JavaScript et localStorage
- Gérer les images, formulaires et animations en dark mode
- Respecter les standards d'accessibilité WCAG
La media query prefers-color-scheme
Introduite dans la spécification CSS Media Queries Level 5,
prefers-color-scheme permet à une feuille de style de détecter
la préférence de thème configurée au niveau du système d'exploitation.
Elle est supportée par tous les navigateurs modernes depuis 2019.
Syntaxe de base
/* Styles par défaut (thème clair) */
body {
background-color: #ffffff;
color: #1a1a1a;
}
/* Surcharge automatique si l'OS est en mode sombre */
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #e4e4e4;
}
}
Les valeurs disponibles
| Valeur | Déclenchement | Cas d'usage |
|---|---|---|
light |
OS en mode clair ou aucune préférence | Thème par défaut (ne nécessite pas de media query) |
dark |
OS en mode sombre activé | Surcharge des couleurs pour le thème sombre |
no-preference |
Ancienne valeur (dépréciée) | Ne plus utiliser — remplacée par light |
Vérification JavaScript
// Lire la préférence système depuis JavaScript
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
// Valeur actuelle
console.log(prefersDark.matches); // true si l'OS est en dark mode
// Écouter les changements en temps réel (l'utilisateur bascule son OS)
prefersDark.addEventListener('change', (event) => {
if (event.matches) {
console.log('L\'utilisateur vient d\'activer le dark mode système');
} else {
console.log('L\'utilisateur a désactivé le dark mode système');
}
});
Support navigateurs
/* Vérification de support CSS (optionnel, très bien supporté) */
@supports (prefers-color-scheme: dark) {
/* Styles supplémentaires uniquement si supporté */
.theme-badge::after {
content: 'Dark mode supporté ✓';
}
}
/* prefers-color-scheme est supporté par :
- Chrome 76+ (2019)
- Firefox 67+ (2019)
- Safari 12.1+ (2019)
- Edge 79+ (2020)
- Couverture globale : ~97% des navigateurs (2024) */
prefers-color-scheme ignoreront simplement le bloc.
Variables CSS pour les thèmes
Les variables CSS (custom properties) sont la clé d'un système
de thèmes maintenable. L'idée est simple : définir toutes vos couleurs comme des
tokens dans :root, puis les surcharger dans le contexte dark.
Vos composants n'utilisent que les variables — ils s'adaptent automatiquement.
Définition des tokens de couleur
/* ==========================================================
DESIGN TOKENS — Système de couleurs centralisé
========================================================== */
/* Thème clair (valeurs par défaut) */
:root {
/* Couleurs de fond */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-tertiary: #e9ecef;
/* Couleurs de texte */
--color-text-primary: #1a1a2e;
--color-text-secondary: #495057;
--color-text-muted: #6c757d;
/* Couleurs de bordure */
--color-border: #dee2e6;
--color-border-strong: #adb5bd;
/* Couleurs d'accent */
--color-accent: #0d6efd; /* bleu Bootstrap */
--color-accent-hover: #0b5ed7;
/* Couleurs de statut */
--color-success: #198754;
--color-warning: #ffc107;
--color-danger: #dc3545;
--color-info: #0dcaf0;
/* Ombres */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.16);
}
/* Surcharge pour le thème sombre */
@media (prefers-color-scheme: dark) {
:root {
/* Couleurs de fond */
--color-bg-primary: #121212;
--color-bg-secondary: #1e1e1e;
--color-bg-tertiary: #2d2d2d;
/* Couleurs de texte */
--color-text-primary: #e4e4e7;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
/* Couleurs de bordure */
--color-border: #3f3f46;
--color-border-strong: #52525b;
/* Couleurs d'accent (légèrement plus claires pour le contraste) */
--color-accent: #4d8bf5;
--color-accent-hover: #6ba0f7;
/* Couleurs de statut (ton pastel pour éviter l'éblouissement) */
--color-success: #4ade80;
--color-warning: #fbbf24;
--color-danger: #f87171;
--color-info: #38bdf8;
/* Ombres plus marquées (fond sombre les atténue) */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
}
}
Utilisation dans les composants
/* Les composants référencent UNIQUEMENT les variables — jamais de couleurs brutes */
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
border-radius: 8px;
padding: 1.5rem;
}
.card-title {
color: var(--color-text-primary);
font-weight: 700;
}
.card-text {
color: var(--color-text-secondary);
}
.btn-primary {
background-color: var(--color-accent);
border-color: var(--color-accent);
color: #ffffff;
}
.btn-primary:hover {
background-color: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
--color-text-primary
à --color-grey-900. Quand le thème change, la sémantique reste cohérente
— votre variable "texte primaire" sera claire en mode clair et claire en mode sombre.
Implémentation complète pas à pas
Voici une implémentation complète d'un mini-système de thèmes. Nous allons créer une page HTML avec header, cards et footer qui basculent proprement entre mode clair et mode sombre selon la préférence système.
Structure HTML de base
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mon site avec dark mode</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<div class="container">
<h1 class="logo">Mon Site</h1>
<nav>
<a href="#">Accueil</a>
<a href="#">Blog</a>
<a href="#">Contact</a>
</nav>
</div>
</header>
<main class="container">
<div class="card">
<h2 class="card-title">Article récent</h2>
<p class="card-text">Contenu de l'article en mode clair et sombre.</p>
<a href="#" class="btn-primary">Lire la suite</a>
</div>
</main>
</body>
</html>
Feuille de style complète (styles.css)
/* ==========================================
1. DESIGN TOKENS (variables CSS)
========================================== */
:root {
--color-bg: #ffffff;
--color-bg-card: #f8f9fa;
--color-text: #212529;
--color-text-sub: #6c757d;
--color-border: #dee2e6;
--color-accent: #0d6efd;
--color-header-bg: #ffffff;
--color-header-border: #e9ecef;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--transition: background-color 0.25s ease, color 0.25s ease,
border-color 0.25s ease, box-shadow 0.25s ease;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f13;
--color-bg-card: #1a1a24;
--color-text: #e2e8f0;
--color-text-sub: #94a3b8;
--color-border: #2d2d3d;
--color-accent: #60a5fa;
--color-header-bg: #0f0f13;
--color-header-border: #1e1e2e;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
}
/* ==========================================
2. RESET ET BASE
========================================== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
transition: var(--transition);
}
/* ==========================================
3. HEADER
========================================== */
.site-header {
background-color: var(--color-header-bg);
border-bottom: 1px solid var(--color-header-border);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow);
transition: var(--transition);
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.site-header nav a {
color: var(--color-text);
text-decoration: none;
margin-left: 1.5rem;
font-weight: 500;
transition: color 0.2s;
}
.site-header nav a:hover {
color: var(--color-accent);
}
/* ==========================================
4. CARDS
========================================== */
.card {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow);
transition: var(--transition);
margin-bottom: 1.5rem;
}
.card-title {
color: var(--color-text);
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.card-text {
color: var(--color-text-sub);
margin-bottom: 1rem;
}
/* ==========================================
5. BOUTON
========================================== */
.btn-primary {
display: inline-block;
background-color: var(--color-accent);
color: #ffffff;
padding: 0.5rem 1.25rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.85;
color: #ffffff;
}
Résultat visuel attendu
En mode clair : fond blanc, texte anthracite `#212529`, cards gris très clair, bouton bleu vif. En mode sombre : fond quasi-noir `#0f0f13`, texte gris-bleuté clair, cards légèrement plus claires que le fond, bouton bleu pastel pour éviter l'éblouissement. La transition CSS de 250 ms rend le basculement fluide lorsque l'utilisateur change la préférence de son OS.
Basculement manuel avec JavaScript
La media query prefers-color-scheme reflète la préférence système,
mais de nombreux utilisateurs veulent choisir le thème indépendamment de leur OS.
La solution standard combine un attribut data-theme sur la balise
<html>, du JavaScript et localStorage pour
mémoriser le choix.
Modifier les variables CSS selon data-theme
/* Stratégie : data-theme sur prime sur prefers-color-scheme */
/* Thème clair (défaut) */
:root,
[data-theme="light"] {
--color-bg: #ffffff;
--color-bg-card: #f8f9fa;
--color-text: #212529;
--color-accent: #0d6efd;
/* ... autres variables ... */
}
/* Thème sombre — préférence système */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-bg: #0f0f13;
--color-bg-card: #1a1a24;
--color-text: #e2e8f0;
--color-accent: #60a5fa;
/* ... */
}
}
/* Thème sombre — choix manuel de l'utilisateur (priorité maximale) */
[data-theme="dark"] {
--color-bg: #0f0f13;
--color-bg-card: #1a1a24;
--color-text: #e2e8f0;
--color-accent: #60a5fa;
/* ... */
}
Bouton de bascule HTML
<!-- Bouton accessible avec aria-label dynamique -->
<button
id="theme-toggle"
class="btn-icon"
aria-label="Activer le mode sombre"
title="Changer de thème"
>
<!-- Icône soleil (mode clair actif) -->
<svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<!-- Icône lune (mode sombre actif) -->
<svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
Script JavaScript complet avec localStorage
/**
* Gestionnaire de thème — dark/light mode avec persistance localStorage
* Doit être chargé AVANT le rendu du body pour éviter le FOUC
* (Flash Of Unstyled Content — flash du mauvais thème)
*/
(function () {
const STORAGE_KEY = 'user-theme';
const html = document.documentElement;
// 1. Lire le thème stocké, sinon utiliser la préférence système
function getInitialTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light') {
return stored; // l'utilisateur a déjà choisi
}
// Fallback : lire la préférence OS
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
// 2. Appliquer le thème sur
function applyTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
// Mettre à jour le bouton toggle
const btn = document.getElementById('theme-toggle');
if (btn) {
btn.setAttribute(
'aria-label',
theme === 'dark' ? 'Activer le mode clair' : 'Activer le mode sombre'
);
}
}
// 3. Appliquer immédiatement (avant le rendu CSS — anti-FOUC)
applyTheme(getInitialTheme());
// 4. Brancher le bouton toggle quand le DOM est prêt
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
btn.addEventListener('click', function () {
const current = html.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// 5. Écouter les changements système en temps réel
// (cas : l'utilisateur change le mode dans son OS pendant la session)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
// Ne réagir au changement système QUE si l'utilisateur n'a pas fait de choix manuel
if (!localStorage.getItem(STORAGE_KEY)) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
});
})();
CSS du bouton avec icônes conditionnelles
/* Afficher la bonne icône selon le thème actif */
/* En mode clair : montrer l'icône lune (pour basculer en dark) */
[data-theme="light"] .icon-sun { display: none; }
[data-theme="light"] .icon-moon { display: block; }
/* En mode sombre : montrer l'icône soleil (pour basculer en light) */
[data-theme="dark"] .icon-moon { display: none; }
[data-theme="dark"] .icon-sun { display: block; }
/* Style du bouton */
.btn-icon {
background: transparent;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.4rem 0.6rem;
cursor: pointer;
color: var(--color-text);
display: flex;
align-items: center;
transition: background-color 0.2s, border-color 0.2s;
}
.btn-icon:hover {
background-color: var(--color-bg-card);
}
.btn-icon svg {
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
<head>, avant tout chargement de CSS. Ainsi, data-theme
est défini sur <html> avant que le navigateur ne peigne la page —
aucun flash du mauvais thème ne sera visible.
Images et médias en dark mode
Les images posent un défi particulier en dark mode : une photo lumineuse peut sembler éblouissante sur fond sombre. CSS propose plusieurs techniques pour y remédier.
Réduire la luminosité des images en mode sombre
/* Atténuer légèrement les images pour réduire le contraste excessif */
@media (prefers-color-scheme: dark) {
img:not([src*=".svg"]),
video {
filter: brightness(0.9) contrast(0.95);
/* brightness(0.9) : -10% luminosité
contrast(0.95) : légèrement moins contrasté */
}
}
/* Avec data-theme */
[data-theme="dark"] img:not([src*=".svg"]),
[data-theme="dark"] video {
filter: brightness(0.85) contrast(0.9);
}
Images différentes par thème via <picture>
<!-- Fournir des versions optimisées pour chaque thème -->
<picture>
<!-- Image spécifique dark mode (fond transparent ou tons sombres) -->
<source
srcset="logo-dark.webp"
media="(prefers-color-scheme: dark)"
>
<!-- Image par défaut (mode clair) -->
<img
src="logo-light.webp"
alt="Logo de l'entreprise"
width="200"
height="60"
>
</picture>
SVG inline adaptés au thème
/* Les SVG inline héritent naturellement de currentColor */
.icon-feature {
width: 48px;
height: 48px;
fill: var(--color-accent); /* couleur principale de l'icône */
stroke: var(--color-border); /* contour si nécessaire */
}
/* Pour les SVG qui doivent être inversés */
@media (prefers-color-scheme: dark) {
.logo-img {
/* Inverser les tons pour un logo sur fond sombre */
filter: invert(1) hue-rotate(180deg);
/* Note : à utiliser avec parcimonie — peut dénaturer les couleurs */
}
}
/* Alternative plus précise : utiliser CSS filter pour ajuster */
[data-theme="dark"] .logo-mono {
/* Pour les logos monochrones : simple inversion */
filter: invert(0.9);
}
Fond de page avec image ou dégradé
/* Dégradé adaptatif selon le thème */
.hero-section {
background-image: linear-gradient(
135deg,
var(--color-bg) 0%,
var(--color-bg-card) 100%
);
padding: 5rem 0;
}
/* En dark mode, le dégradé s'adapte automatiquement via les variables */
/* Fond avec image + overlay adaptatif */
.banner {
background-image:
linear-gradient(
to bottom,
rgba(var(--overlay-rgb), 0.6),
rgba(var(--overlay-rgb), 0.85)
),
url('banner.webp');
background-size: cover;
background-position: center;
}
:root {
--overlay-rgb: 255, 255, 255; /* overlay clair sur fond clair */
}
[data-theme="dark"] {
--overlay-rgb: 0, 0, 0; /* overlay sombre sur fond sombre */
}
Formulaires et composants UI
Les champs de formulaire natifs (input, select, textarea) ont des styles par défaut imposés par le navigateur. En dark mode, ils peuvent apparaître incohérents avec votre design. Voici comment les harmoniser.
Stylisation des inputs pour le dark mode
/* Variables de formulaire */
:root {
--input-bg: #ffffff;
--input-color: #212529;
--input-border: #ced4da;
--input-placeholder: #6c757d;
--input-focus-bg: #ffffff;
--input-focus-ring: rgba(13, 110, 253, 0.25);
}
[data-theme="dark"] {
--input-bg: #1e1e2e;
--input-color: #e2e8f0;
--input-border: #3d3d52;
--input-placeholder: #64748b;
--input-focus-bg: #252535;
--input-focus-ring: rgba(96, 165, 250, 0.3);
}
/* Styles des inputs */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
select,
textarea {
background-color: var(--input-bg);
color: var(--input-color);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 1rem;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s, background-color 0.25s;
/* Forcer le color-scheme pour que le navigateur adapte les éléments natifs */
color-scheme: var(--input-color-scheme, light);
}
:root {
--input-color-scheme: light;
}
[data-theme="dark"] {
--input-color-scheme: dark;
}
input::placeholder,
textarea::placeholder {
color: var(--input-placeholder);
opacity: 1;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
background-color: var(--input-focus-bg);
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--input-focus-ring);
}
La propriété color-scheme — astuce native
/* color-scheme indique au navigateur le schéma de l'élément.
Il adapte automatiquement : scrollbars, champs de date, select... */
/* Sur l'ensemble de la page */
:root {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark; /* Scrollbars, inputs natifs, et autres UI natifs s'adaptent */
}
/* La meta tag équivalente dans — utile pour le rendu initial */
/* */
/* Résultat : les scrollbars seront claires en light, sombres en dark
automatiquement, sans CSS supplémentaire */
Tables, badges et alertes
/* Variables pour les tables */
:root {
--table-header-bg: #f8f9fa;
--table-stripe-bg: #f2f2f2;
--table-hover-bg: #e9ecef;
}
[data-theme="dark"] {
--table-header-bg: #1a1a2e;
--table-stripe-bg: #16161f;
--table-hover-bg: #252535;
}
table {
width: 100%;
border-collapse: collapse;
background-color: var(--color-bg-card);
color: var(--color-text);
}
th {
background-color: var(--table-header-bg);
color: var(--color-text);
padding: 0.75rem 1rem;
font-weight: 600;
border-bottom: 2px solid var(--color-border);
text-align: left;
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
}
tr:nth-child(even) {
background-color: var(--table-stripe-bg);
}
tr:hover {
background-color: var(--table-hover-bg);
}
/* Alertes adaptées */
.alert-info {
background-color: color-mix(in srgb, var(--color-info) 15%, var(--color-bg-card));
border-left: 4px solid var(--color-info);
color: var(--color-text);
padding: 1rem;
border-radius: 0 6px 6px 0;
}
Animations et transitions de thème
Une transition fluide entre les thèmes améliore considérablement l'expérience utilisateur. Voici les techniques pour rendre le basculement visuellement agréable sans impacter les performances.
Transition globale sur les propriétés de couleur
/* ✅ Transition légère — anime uniquement les changements de couleur */
body,
.card,
.site-header,
input,
select,
textarea,
button {
transition:
background-color 0.25s ease,
color 0.25s ease,
border-color 0.25s ease,
box-shadow 0.25s ease;
}
/* ❌ À éviter : transition universelle (*) — trop coûteux */
/* * { transition: all 0.3s ease; } — anime aussi width, height, transform... */
Désactiver les transitions au chargement (anti-FOUC avancé)
/**
* Désactiver toutes les transitions au chargement initial
* pour éviter d'animer depuis le thème "par défaut" vers le thème sauvegardé
*/
/* CSS : classe no-transition */
.no-transition,
.no-transition * {
transition: none !important;
}
/* JavaScript : appliquer au chargement, retirer après */
(function () {
// Appliquer le thème IMMÉDIATEMENT (avant CSS)
const theme = localStorage.getItem('user-theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
// Désactiver les transitions temporairement
document.documentElement.classList.add('no-transition');
// Les réactiver après le premier paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
});
})();
Animation de bascule style "ripple"
/* Animation circulaire lors du toggle — effet visuel premium */
@keyframes theme-ripple {
from {
clip-path: circle(0% at var(--ripple-x, 50%) var(--ripple-y, 50%));
}
to {
clip-path: circle(150% at var(--ripple-x, 50%) var(--ripple-y, 50%));
}
}
/* JavaScript pour lancer l'animation depuis la position du bouton */
document.getElementById('theme-toggle').addEventListener('click', function (e) {
const rect = e.currentTarget.getBoundingClientRect();
const x = ((rect.left + rect.width / 2) / window.innerWidth * 100).toFixed(1) + '%';
const y = ((rect.top + rect.height / 2) / window.innerHeight * 100).toFixed(1) + '%';
document.documentElement.style.setProperty('--ripple-x', x);
document.documentElement.style.setProperty('--ripple-y', y);
/* Démarrer l'animation puis basculer le thème */
document.documentElement.style.animation = 'theme-ripple 0.5s ease forwards';
setTimeout(() => {
// Changer le thème
const current = document.documentElement.getAttribute('data-theme');
document.documentElement.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
// Nettoyer
document.documentElement.style.animation = '';
}, 250);
});
Respecter prefers-reduced-motion
/* Certains utilisateurs ont désactivé les animations (épilepsie, vertiges...) */
/* Toujours respecter cette préférence */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* En JS également */
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function animateToggle() {
if (prefersReducedMotion) {
/* Changer directement sans animation */
applyTheme(newTheme);
} else {
/* Lancer l'animation ripple */
triggerRippleAnimation(newTheme);
}
}
Accessibilité et contraste
Le dark mode n'est accessible que si les ratios de contraste WCAG sont respectés. Un thème sombre mal conçu peut être plus difficile à lire qu'un fond blanc standard. Les normes WCAG 2.1 imposent des ratios minimaux indépendamment du thème.
Ratios de contraste WCAG 2.1
| Niveau | Texte normal (< 18px) | Grand texte (≥ 18px ou gras ≥ 14px) | Composants UI |
|---|---|---|---|
| AA (minimum) | 4.5:1 | 3:1 | 3:1 |
| AAA (optimal) | 7:1 | 4.5:1 | — |
Exemple de palettes conformes WCAG
/* ✅ CONFORME WCAG AA — fond très sombre + texte clair */
[data-theme="dark"] {
/* Texte principal sur fond principal */
--color-bg: #121212; /* fond */
--color-text: #e4e4e7; /* texte — ratio ~14.5:1 ✓ */
/* Texte secondaire sur fond principal */
--color-text-sub: #a1a1aa; /* ratio ~4.8:1 ✓ */
/* Accent (liens, boutons) sur fond */
--color-accent: #60a5fa; /* bleu clair — ratio ~4.6:1 sur #121212 ✓ */
}
/* ❌ À ÉVITER — contraste insuffisant */
[data-theme="dark"] {
--color-bg: #1e1e1e;
--color-text: #888888; /* texte gris moyen — ratio ~3.5:1 ✗ en AA */
}
Outils de vérification du contraste
/* Outils recommandés (dans votre workflow dev) :
- WebAIM Contrast Checker (webaim.org/resources/contrastchecker)
- Colour Contrast Analyser (app desktop, Paciello Group)
- axe DevTools (extension Chrome/Firefox — audit WCAG automatisé)
- Lighthouse (onglet Accessibility dans Chrome DevTools)
En JavaScript, calculer un ratio approximatif : */
function getLuminance(r, g, b) {
// Formule WCAG pour la luminance relative
const a = [r, g, b].map(v => {
v /= 255;
return v <= 0.03928
? v / 12.92
: Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
function getContrastRatio(hex1, hex2) {
// Convertir HEX en RGB
const toRgb = hex => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
: [0, 0, 0];
};
const l1 = getLuminance(...toRgb(hex1));
const l2 = getLuminance(...toRgb(hex2));
const bright = Math.max(l1, l2);
const dark = Math.min(l1, l2);
return ((bright + 0.05) / (dark + 0.05)).toFixed(2);
}
// Exemple d'utilisation
console.log(getContrastRatio('#121212', '#e4e4e7')); // → ~14.5 ✓
Aria et annonces de changement de thème
<!-- Zone aria-live pour annoncer le changement de thème aux lecteurs d'écran -->
<div
id="theme-announcement"
class="visually-hidden"
aria-live="polite"
aria-atomic="true"
></div>
<!-- CSS pour masquer visuellement mais garder accessible -->
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
<!-- JavaScript : annoncer le changement -->
<script>
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('user-theme', theme);
// Annoncer aux lecteurs d'écran
const msg = document.getElementById('theme-announcement');
if (msg) {
msg.textContent = theme === 'dark'
? 'Mode sombre activé'
: 'Mode clair activé';
}
}
</script>
Bonnes pratiques et pièges courants
1. Ne jamais coder des couleurs brutes dans les composants
/* ❌ Mauvais : couleur brute dans le composant */
.card-header {
background-color: #f8f9fa; /* impossible à thématiser ! */
color: #212529;
}
/* ✅ Correct : toujours via une variable */
.card-header {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
2. Gérer le cas "premier chargement" sans localStorage
/* Sur le premier chargement, l'utilisateur n'a pas encore fait de choix.
La media query CSS prend alors le relais automatiquement.
En JS, toujours vérifier localStorage AVANT d'appliquer un thème par défaut. */
function getTheme() {
const stored = localStorage.getItem('user-theme');
// Priorité 1 : choix explicite de l'utilisateur
if (stored === 'dark' || stored === 'light') return stored;
// Priorité 2 : préférence système
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
// Priorité 3 : thème par défaut
return 'light';
}
3. Tester avec le DevTools Chrome et Firefox
/* Chrome DevTools — Simuler prefers-color-scheme :
1. Ouvrir DevTools (F12)
2. Onglet "Rendering" (via les 3 points → More tools)
3. Dérouler "Emulate CSS media feature prefers-color-scheme"
4. Choisir "dark" ou "light"
Firefox DevTools :
1. Ouvrir DevTools (F12)
2. Dans la barre d'outils DevTools, cliquer l'icône lune/soleil
3. Basculer entre dark et light
Sans DevTools — test rapide depuis la console :
*/
// Simuler le basculement depuis la console
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-theme', 'light');
4. Penser aux prints et vues imprimées
/* Forcer le thème clair pour l'impression — toujours */
@media print {
:root,
[data-theme="dark"] {
--color-bg: #ffffff !important;
--color-bg-card: #f8f9fa !important;
--color-text: #000000 !important;
--color-border: #cccccc !important;
}
/* Masquer les éléments non pertinents à l'impression */
.site-header,
#theme-toggle,
.sidebar,
.ad-banner {
display: none !important;
}
}
5. Éviter l'excès de variables CSS
/* ❌ Trop granulaire — difficile à maintenir */
:root {
--card-title-dark-hover-color: #93c5fd;
--card-body-muted-text-color-sm: #9ca3af;
/* 200 variables... impossibles à mémoriser */
}
/* ✅ Niveau d'abstraction équilibré — ~15-25 variables suffisent */
:root {
--color-bg-primary: …;
--color-bg-secondary: …;
--color-text-primary: …;
--color-text-muted: …;
--color-accent: …;
--color-border: …;
--color-success: …;
--color-danger: …;
--shadow-sm: …;
--shadow-md: …;
--transition-colors: …;
/* Une dizaine de tokens, réutilisés partout */
}
- Tous les ratios de contraste WCAG AA vérifiés (texte + composants)
- Aucune couleur brute dans les composants (uniquement des variables CSS)
- Anti-FOUC implémenté (script dans
<head>) color-schemedéfini sur:root- Images testées : pas d'éblouissement en mode sombre
- Formulaires stylisés et cohérents dans les deux thèmes
prefers-reduced-motionrespecté- Toggle accessible (aria-label dynamique + annonce aria-live)
- Impression forcée en thème clair
- Testé sur Chrome, Firefox, Safari (mobile inclus)
Conclusion et ressources
Implémenter un dark mode de qualité n'est plus réservé aux grandes applications.
Avec la media query prefers-color-scheme, les variables CSS et
quelques lignes de JavaScript, vous pouvez offrir une expérience soignée qui
respecte les préférences de chaque utilisateur.
La clé d'une implémentation réussie tient en trois principes : centraliser
les couleurs dans des variables CSS sémantiques, respecter
la préférence système par défaut, et permettre la surcharge
manuelle via localStorage. À cela s'ajoutent les bonnes
pratiques d'accessibilité — ratios WCAG, prefers-reduced-motion,
annonces ARIA — qui font la différence entre un dark mode décoratif et un dark
mode réellement inclusif.
Points clés à retenir
@media (prefers-color-scheme: dark)— détection native du mode sombre- Variables CSS dans
:root— la seule source de vérité pour les couleurs [data-theme="dark"]+ JavaScript — pour le toggle manuel avec persistance- Script dans
<head>— anti-FOUC indispensable color-scheme: dark— adapte les UI natifs (scrollbars, inputs)- Contraste WCAG AA minimum 4.5:1 — obligatoire pour les deux thèmes
Ressources recommandées
- MDN — prefers-color-scheme — Documentation officielle avec exemples et tableau de compatibilité navigateurs.
- web.dev — Hello darkness, my old friend — Guide approfondi de Google sur l'implémentation du dark mode.
- WebAIM Contrast Checker — Vérifier vos ratios de contraste WCAG en ligne.
- MDN — color-scheme — Propriété CSS pour adapter les UI natifs du navigateur au thème actif.