Gérer le Dark Mode moderne en Sass avec color-scheme et light-dark()
La fonction CSS native light-dark() simplifie radicalement la gestion du thème sombre en éliminant la duplication de variables dans des blocs @media (prefers-color-scheme: dark). Couplée à Sass, elle permet de piloter une palette complète via des tables de tokens légères et sans redondance.
1. Comment fonctionne le duo color-scheme et light-dark()
Pour que light-dark(couleurClaire, couleurSombre) s'active, le navigateur doit impérativement savoir quel mode appliquer. C'est le rôle de la propriété color-scheme :
:root {
/* Indique que la page supporte les deux modes système */
color-scheme: light dark;
/* light-dark(valeur-si-light, valeur-si-dark) */
--bg-body: light-dark(#ffffff, #0f172a);
--text-main: light-dark(#1e293b, #f8fafc);
}
-
Si le système de l'utilisateur est en mode sombre,
--bg-bodyvaut#0f172a. -
Si l'utilisateur est en mode clair,
--bg-bodyvaut#ffffff. -
Les éléments natifs (barres de défilement, champs de formulaires, boutons radios) adaptent automatiquement leurs contrastes grâce à
color-scheme.
2. Gérer le choix utilisateur (système + toggle manuel)
En production, un site doit généralement respecter la préférence de l'OS tout en laissant la possibilité de basculer manuellement via un attribut (par exemple data-theme).
L'avantage majeur de light-dark() est qu'il n'y a aucune variable à redéfinir. Il suffit de forcer la valeur de color-scheme :
// Par défaut : suit l'OS
:root {
color-scheme: light dark;
}
// Forçage explicite via JavaScript
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
Dès que color-scheme passe à dark, toutes les propriétés utilisant light-dark() basculent instantanément sur leur variante sombre.
3. Architecture des tokens avec Sass
Plutôt que d'écrire chaque directive à la main, nous déclarons un dictionnaire (Map) Sass listant les paires (couleur-claire, couleur-sombre), puis nous générons automatiquement les propriétés personnalisées.
Le fichier de configuration _tokens.scss
// Définition des tokens : clé => (valeur-light, valeur-dark)
$theme-tokens: (
"bg-body": (#ffffff, #0b0f19),
"bg-surface": (#f8fafc, #131c2e),
"bg-card": (#ffffff, #1e293b),
"text-main": (#0f172a, #f8fafc),
"text-muted": (#64748b, #94a3b8),
"border": (#e2e8f0, #334155),
"primary": (#2563eb, #3b82f6),
"primary-hover": (#1d4ed8, #60a5fa)
);
La génération automatisée _theme.scss
Dans les versions récentes de Dart Sass, on utilise l'interpolation #{...} pour s'assurer que le compilateur traite light-dark() comme une fonction CSS native sans tenter de l'évaluer en Sass :
@use "sass:list";
@use "tokens" as *;
:root {
color-scheme: light dark;
// Itération sur la map pour générer chaque variable CSS
@each $name, $colors in $theme-tokens {
$light-color: list.nth($colors, 1);
$dark-color: list.nth($colors, 2);
--#{$name}: #{light-dark($light-color, $dark-color)};
}
}
// Prise en charge des surcharges manuelles
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
4. Utilisation concrète dans les composants
Une fois les variables injectées au :root, les composants SCSS consomment directement les variables CSS sans jamais avoir à se soucier du mode actif ou d'écrire des media queries.
// _card.scss
.card {
background-color: var(--bg-card);
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
transition: border-color 0.2s ease, background-color 0.2s ease;
&__title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
&__desc {
color: var(--text-muted);
}
&__button {
background-color: var(--primary);
color: #ffffff;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: var(--primary-hover);
}
}
}
5. Rendu CSS compilé
Après compilation par Dart Sass, voici le code CSS net obtenu :
:root {
color-scheme: light dark;
--bg-body: light-dark(#ffffff, #0b0f19);
--bg-surface: light-dark(#f8fafc, #131c2e);
--bg-card: light-dark(#ffffff, #1e293b);
--text-main: light-dark(#0f172a, #f8fafc);
--text-muted: light-dark(#64748b, #94a3b8);
--border: light-dark(#e2e8f0, #334155);
--primary: light-dark(#2563eb, #3b82f6);
--primary-hover: light-dark(#1d4ed8, #60a5fa);
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
.card {
background-color: var(--bg-card);
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.card__title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.card__desc {
color: var(--text-muted);
}
.card__button {
background-color: var(--primary);
color: #ffffff;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.card__button:hover {
background-color: var(--primary-hover);
}
6. Points d'attention
-
Scope local :
color-schemepeut aussi être appliqué localement sur un conteneur particulier (ex..section-dark { color-scheme: dark; }). Tous les éléments enfants utilisantlight-dark()adopteront alors les couleurs du mode sombre, indépendamment du reste de la page. -
Compatibilité navigateur :
light-dark()est supporté par tous les moteurs modernes (Chrome 123+, Firefox 120+, Safari 17.5+). -
Limitations :
light-dark()n'accepte que des types<color>. Il ne peut pas être utilisé pour permuter des dimensions (width), des marges ou des ombres non colorées.
7. Pour aller plus loin : ajouter un bouton de bascule manuel (JavaScript)
À ce stade, votre thème réagit automatiquement aux réglages système du visiteur sans aucun script. Si vous souhaitez en plus proposer un bouton pour forcer manuellement le mode clair ou sombre et retenir ce choix, voici l'implémentation minimale :
Pour éviter l'effet de scintillement au chargement (FOUC – Flash of Unstyled Content) tout en synchronisant l'état avec l'attribut data-theme, le script se découpe en deux parties : une initialisation immédiate dans le <head> et la gestion de l'événement de bascule.
1. Script d'initialisation (à placer dans le <head>)
Ce script synchrone ultra-léger applique le thème sauvegardé avant l'affichage du HTML pour garantir zéro flash visuel :
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.dataset.theme = savedTheme;
}
})();
</script>
2. Le bouton HTML
<button id="theme-toggle" type="button" aria-label="Changer de thème">
🌓 Thème
</button>
3. Le script de bascule (JS principal / différé)
Ce script détermine le thème actif (en vérifiant localStorage, puis la préférence de l'OS via prefers-color-scheme), applique la valeur opposée et la stocke.
const themeToggleBtn = document.getElementById('theme-toggle');
// Détermine le thème effectif (attribut explicite ou détection système)
function getActiveTheme() {
return document.documentElement.dataset.theme ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
// Bascule entre 'light' et 'dark'
themeToggleBtn?.addEventListener('click', () => {
const nextTheme = getActiveTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = nextTheme;
localStorage.setItem('theme', nextTheme);
});
// Écoute les changements de thème de l'OS (uniquement si l'utilisateur n'a rien forcé manuellement)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!localStorage.getItem('theme')) {
delete document.documentElement.dataset.theme;
}
});
Fonctionnement
-
Par défaut : Si aucune préférence n'est en cache,
data-themereste absent du DOM. Le navigateur applique nativementcolor-scheme: light dark;et s'adapte à l'OS. -
Au clic : Le script résout si l'état actuel est sombre ou clair (même s'il découle de l'OS), puis injecte
data-theme="light"oudata-theme="dark"sur<html>, activant instantanément les règles SCSS vues précédemment. -
Persistance : Au rechargement de la page, le script placé dans le
<head>réapplique immédiatement l'attribut avant le rendu des styles.

