Switch de thème sans JavaScript

En créant mon site, j'espérais parler peu de technologies et finalement, mes deux premiers sont dédiés à ça. Mais je sais déjà que le troisième sera sur une autre thématique.

En attendant, pour lire cet article, il faut avoir quelques bases en HTML/CSS et éventuellement JavaScript. « Déso pas déso » comme on disait il fût un temps.

L'objectif

Je souhaitais proposer un bouton qui permet de basculer entre un thème clair et un thème sombre. On trouve beaucoup d'exemples en ligne mais ils ne me convenaient pas pour deux raisons :

  1. Soit ils utilisent beaucoup de JavaScript, hors je n'apprécie pas de mettre du JavaScript là où ça a peu de sens ;
  2. Soit ils ne permettaient pas de changer le thème de manière aussi complète que je le souhaitais (notamment avec le bouton un peu là où je le souhaites, voire un bouton dans le menu principal et un dans les informations de bas de page).

En m'inspirant tout de même de ce que j'ai trouvé à droite et à gauche, mais aussi avec mes propres réfléxions, j'ai réussi à faire ce que je voulais. Je discuterais des avantages et inconvénients de ma solution à la fin de l'article.

Implémentation étape par étape

Pour faire simple, les exemples que je vais montrer utilisent des couleurs très simplistes.

Un thème selon la préférence du navigateur

Il est possible de régler une préférence entre le thème clair et le thème sombre dans le navigateur. La première étape est d'appliquer celle-ci.

En CSS, la media query prefers-color-scheme permet d'obtenir cette préférence. En la combinant avec l'élément :root et les variables CSS, on devrait pouvoir s'amuser un peu.

Déjà, on peut simplement créer et appliquer des couleurs de thème clair :

1:root {
2 --color-background: white;
3 --color-foreground: black;
4 --color-accent: blue;
5}
6
7body {
8 background-color: var(--color-background);
9 color: var(--color-foreground);
10}
11
12h1, h2, h3, h4, h5, h5, strong {
13 color: var(--color-accent);
14}

Maintenant, préparons un peu le terrain avec des couleurs « inversées » qui seront utilisées pour le thème sombre, mais en utilisant seulement celles du thème clair :

1:root {
2 --color-light-background: white;
3 --color-light-foreground: black;
4 --color-light-accent: blue;
5
6 --color-dark-background: black;
7 --color-dark-foreground: white;
8 --color-dark-accent: red;
9
10 --color-background: var(--color-light-background);
11 --color-foreground: var(--color-light-foreground);
12 --color-accent: var(--color-light-accent);
13}
14
15body {
16 background-color: var(--color-background);
17 color: var(--color-foreground);
18}
19
20h1, h2, h3, h4, h5, h5, strong {
21 color: var(--color-accent);
22}

Si vous avez regardé le lien qui documente prefers-color-scheme, vous aurez vite compris qu'il devient facile d'appliquer les couleurs inversées quand le navigateur est configuré pour un thème clair :

1:root {
2 --color-light-background: white;
3 --color-light-foreground: black;
4 --color-light-accent: blue;
5
6 --color-dark-background: black;
7 --color-dark-foreground: white;
8 --color-dark-accent: red;
9
10 --color-background: var(--color-light-background);
11 --color-foreground: var(--color-light-foreground);
12 --color-accent: var(--color-light-accent);
13}
14
15@media (prefers-color-scheme: dark) {
16 :root {
17 --color-background: var(--color-dark-background);
18 --color-foreground: var(--color-dark-foreground);
19 --color-accent: var(--color-dark-accent);
20 }
21}
22
23body {
24 background-color: var(--color-background);
25 color: var(--color-foreground);
26}
27
28h1, h2, h3, h4, h5, h5, strong {
29 color: var(--color-accent);
30}

Jusque là, on a donc un thème clair et un thème sombre, l'un ou l'autre étant appliqué selon la préférence du navigateur (normalement contrôlé par l'utilisatrice ou l'utilisateur de celui-ci, toutefois pas tout le monde ne connaît cette possibilité).

Thème par défaut

Il est volontaire de ma part de ne pas utiliser une requête @media (prefers-color-scheme: light).

Pourquoi ? Car si un navigateur ne supporte pas prefers-color-scheme, ça évite de devoir faire une solution de secours. Toutefois, ce cher Can I Use indique un support assez étendu (on ne remercie pas Internet Explorer et Opera Mini qui sont un peu en retard, leurs parts de marché rendant la chose peu problématique tout de même).

Si le souhait est d'appliquer le thème sombre par défaut et le thème clair là où il est souhaité, il suffit d'inverser les valeurs par défaut pour utiliser le thème sombre et de changer la media query pour utiliser @media (prefers-color-scheme: light) et d'y utiliser les couleurs claires.

Ajout d'un switch en pur HTML/CSS : solution habituelle

J'expliquais au début de cet article que je souhaite répondre à une éventualité (que j'ai d'ailleurs appliqué sur ce site) : le besoin ou l'envie d'afficher plusieurs switchs.

La technique purement HTML/CSS se base généralement sur le fait d'avoir un <input type="checkbox"> et un <label> qui sont proches et qui précèdent le conteneur de l'application :

1<input type="checkbox" id="theme-switch-state">
2<label for="theme-switch-state">SWITCH</label>
3
4<div id="content"><!----></div>

Cela permet de créer un fichier CSS simpliste, sans besoin d'utiliser :root d'ailleurs :

1/* L'input est caché, on clique sur le label qui est très personnalisable */
2#theme-switch-state {
3 display: none;
4}
5
6#content {
7 --color-light-background: white;
8 --color-light-foreground: black;
9 --color-light-accent: blue;
10
11 --color-dark-background: black;
12 --color-dark-foreground: white;
13 --color-dark-accent: red;
14
15 --color-background: var(--color-light-background);
16 --color-foreground: var(--color-light-foreground);
17 --color-accent: var(--color-light-accent);
18}
19
20@media (prefers-color-scheme: dark) {
21 #content {
22 --color-background: var(--color-dark-background);
23 --color-foreground: var(--color-dark-foreground);
24 --color-accent: var(--color-dark-accent);
25 }
26}
27
28#theme-switch-state:checked ~ #content {
29 --color-background: var(--color-dark-background);
30 --color-foreground: var(--color-dark-foreground);
31 --color-accent: var(--color-dark-accent);
32}
33
34@media (prefers-color-scheme: dark) {
35 #theme-switch-state:checked ~ #content {
36 --color-background: var(--color-light-background);
37 --color-foreground: var(--color-light-foreground);
38 --color-accent: var(--color-light-accent);
39 }
40}

Sauf que ça force la création des éléments dans l'ordre et que du coup, si l'élément <input> est dans un menu, on ne peut déjà pas changer la couleur de ce menu.

Ma solution se base donc sur le besoin (ou l'envie) de mettre le <input type="checkbox" id="theme-switch-state"> où on souhaite et de créer autant de <label for="theme-switch-state"> qu'on le désire, là où on le désire.

Ajout d'un switch en pur HTML/CSS : solution « miracle »

Notons les guillemets du titre ci-dessus : il n'y a pas de solution miracle et j'évoquerais les inconvénients de ma solution à la fin de cet article.

J'utilise simplement l'élément :root avec le récent sélecteur :has et la (pas si récente) pseudo-class :checked. Du coup, en gardant l'ID theme-switch-state sur l'input, on peut écrire le code CSS suivant qui fonctionne :

1:root {
2 --color-light-background: white;
3 --color-light-foreground: black;
4 --color-light-accent: blue;
5
6 --color-dark-background: black;
7 --color-dark-foreground: white;
8 --color-dark-accent: red;
9
10 --color-background: var(--color-light-background);
11 --color-foreground: var(--color-light-foreground);
12 --color-accent: var(--color-light-accent);
13}
14
15/**
16 * Quand le thème est clair par défaut et qu'on a cliqué sur le label un nombre impair de fois (donc qu'on a coché l'input de type checkbox),
17 * on change les variables de base pour utiliser les valeurs sombres
18 */
19:root:has(#theme-switch-state:checked) {
20 --color-background: var(--color-dark-background);
21 --color-foreground: var(--color-dark-foreground);
22 --color-accent: var(--color-dark-accent);
23}
24
25@media (prefers-color-scheme: dark) {
26 :root {
27 --color-background: var(--color-dark-background);
28 --color-foreground: var(--color-dark-foreground);
29 --color-accent: var(--color-dark-accent);
30 }
31
32 /**
33 * Quand le thème est sombre par défaut et qu'on a cliqué sur le label un nombre impair de fois (donc qu'on a coché l'input de type checkbox),
34 * on change les variables de base pour utiliser les valeurs claires
35 */
36 :root:has(#theme-switch-state:checked) {
37 --color-background: var(--color-dark-background);
38 --color-foreground: var(--color-dark-foreground);
39 --color-accent: var(--color-dark-accent);
40 }
41}
42
43body {
44 background-color: var(--color-background);
45 color: var(--color-foreground);
46}
47
48h1, h2, h3, h4, h5, h5, strong {
49 color: var(--color-accent);
50}

Et voilà, on a un bouton pleinement fonctionnel, en pur HTML/CSS, avec la possibilité de mettre plein de <label for="theme-switch-state"> pour que nos visiteuses et visiteurs puissent changer le thème à volonté.

Sauvegarde de l'état

Un inconvénient de cette méthode est que la <input type="checkbox"> ne garde pas forcément son status coché ou décoché entre deux visites. Firefox a l'air de le garder si le cache n'est pas rafraichi en même temps que la page mais Chrome l'efface au moindre changement de page.

Malheureusement (pour moi), je n'ai pas trouvé comment sauvegarder cet état sans JavaScript. Toutefois, le script est très simpliste : il se base sur le classique localStorage et la lecture de l'évènement change sur l'<input> qui gère l'état du thème.

Déjà, posons les bases en écoutant les changements d'état de l'<input> et en l'enregistrant sur le localStorage :

1const localStorageThemeSwitchKey = 'theme-switch-state'; // la clé qu'on utilisera dans l'object storage
2const themeState = document.getElementById('theme-switch-state'); // l'élément <input> qui contient notre état
3
4themeState.addEventListener('change', () => {
5 if (themeState.checked) {
6 window.localStorage.setItem(localStorageThemeSwitchKey, 'checked');
7 } else {
8 window.localStorage.removeItem(localStorageThemeSwitchKey);
9 }
10});

On comprend facilement qu'il suffit alors d'utiliser localStorage.getItem(…) au chargement de la page pour rétablir l'état du thème :

1if (window.localStorage.getItem(localStorageThemeSwitchKey) !== null) {
2 themeState.checked = true; // c'est presque trop facile mais ça marche vraiment
3}

Et voilà, on a une sauvegarde de l'état qui survit tant que le localStorage n'est pas vidé !

Voici le code complet, que j'encapsule dans un scope parce que je suis un peu maniaque :

1// Ce script est chargé juste après la balise `<input type="checkbox" id="theme-switch-state">`
2(function() {
3 const localStorageThemeSwitchKey = 'theme-switch-state';
4 const themeState = document.getElementById('theme-switch-state');
5
6 /**
7 * On pourrait faire ça sur un window.onload ou mieux, un window.addEventListener('load'),
8 * toutefois je souhaite que le thème soit appliqué au plus tôt donc je le fais de manière synchrone.
9 */
10 if (window.localStorage.getItem(localStorageThemeSwitchKey) !== null) {
11 themeState.checked = true;
12 }
13
14 themeState.addEventListener('change', () => {
15 if (themeState.checked) {
16 window.localStorage.setItem(localStorageThemeSwitchKey, 'checked');
17 } else {
18 window.localStorage.removeItem(localStorageThemeSwitchKey);
19 }
20 });
21})();

On n'oublie pas de passer un outil de minification dessus, ça fait toujours quelques octets de gagnés.

Avantages et inconvénients

Ça fonctionne, c'est tout ce qu'on voulait. Mais il est bon de se poser la question : est-ce que c'est la solution parfait ? On sait déjà que la réponse est : ça n'existe pas. Alors quels avantages et inconvénients j'y trouve ?

Les avantages selon mois :

  • S'adapte au thème choisi par défaut par la visiteuse ou le visiteur ;
  • La sauvegarde de l'état est très simple à mettre en place ;
  • On peut mettre autant de switchs qu'on veut puisque ce sont des <label> qui peuvent pointer vers la même <input>.

Les inconvénients de mon point de vue :

  • Pour garder l'état, il faut un peu de JavaScript (je chipote beaucoup peut-être, c'est vraiment léger) ;
  • Le sélecteur :root:has(#theme-switch-state:checked), surtout la partir :root:has(…) doit être assez complexe à gérer pour le navigateur, je ne m'y connais pas assez pour savoir mais je sais que mon site n'a pas un DOM immense donc j'en déduis (peut-être maladroitement) que dans ce cas, ça ne doit pas être un problème réel ;
  • La compatibilité n'est valable que pour les navigateurs très récents (en tout cas, au 16 février 2024), notamment le :has(…) en CSS n'est pas supporté de partout (d'après Can I Use, au moment d'écrire ces lignes, on peut tabler sur presque 92% en global et presque 89% en France et pour une fois, c'est Firefox qui plombe un peu le score).

Dans ce qui me semble être un point d'amélioration sans être réellement un inconvénient, si une personne change sa préférence de thème sur son navigateur, ça va inverser le thème (puisque l'état sauvegardé ne dépend pas de la préférence du navigateur). C'est potentiellement indésiré et il faudrait avoir un script un peu plus intelligent qui utilise window.matchMedia('(prefers-color-scheme: /* light|dark */)').matches pour garder l'état selon la préférence qui était en place. Toutefois, ça me semble très mineur et je préfère avoir un script le plus simple possible.