Sélecteur de thème fiable : bouton, menu et préférence mémorisée
Quelqu’un se plaindra de votre sélecteur de thème. Si vous ne proposez que le mode clair, on vous écrira à 2h du matin depuis un téléphone dans une pièce sombre. Si vous proposez le mode sombre mais qu’il y a un flash blanc d’une frame, on vous accusèra de provoquer des migraines. Si la préférence n’est pas persistée, on vous le reprochera à chaque chargement de page — une catégorie de plainte qui fait douter les ingénieurs de leur vocation.
Voici un guide orienté production pour construire une interface de changement de thème en HTML/CSS simple et un peu de JavaScript : un bouton bascule, un menu déroulant avec une option « Système », et une préférence mémorisée. L’objectif n’est pas « ça marche sur ma machine ». L’objectif est « ça marche dans le monde réel où les navigateurs bloquent le stockage, où les pages sont mises en cache et où quelqu’un mesure votre CLS ».
Exigences importantes en production
Le changement de thème ressemble à une finition UI jusqu’à ce que vous le déployiez à grande échelle. Ensuite, c’est une fonctionnalité de fiabilité : elle touche la performance, l’accessibilité, la mise en cache et parfois les revues de sécurité (tout ce qui écrit dans le stockage suscite des questions). Définissez les exigences dès le départ, car « mode sombre » n’est pas une exigence ; c’est un tas de cas limites déguisés.
Exigences de base (non négociables)
- Logique à trois états :
light,darketsystem. Les utilisateurs attendent « suivre le système ». Les entreprises aussi, car l’informatique impose parfois des politiques système. - Persistance : mémoriser le choix explicite de l’utilisateur entre les rechargements et sessions. Si le stockage est bloqué, dégrader gracieusement le comportement.
- Pas de flash du mauvais thème : éviter le « flash blanc » au premier rendu quand l’utilisateur préfère le sombre. Ce n’est pas cosmétique ; c’est une atteinte à l’UX.
- Contrôles accessibles : un bouton bascule correctement étiqueté et un menu déroulant utilisable au clavier. Si ce n’est pas accessible, ce n’est pas terminé.
- Faible complexité : pas de frameworks obligatoires. Petit JS. CSS simple. Moins de surface = moins de bugs.
- Valeurs par défaut sûres : si quelque chose échoue (exceptions de stockage, JS désactivé), la page reste lisible.
Atouts appréciables qui valent l’investissement
- Thèmes multiples : même si vous ne livrez que clair/sombre aujourd’hui, structurez le CSS pour que l’ajout de « sépia » ou « contraste élevé » ne nécessite pas une réécriture complète.
- Points de télémétrie : pas besoin d’analytics intrusifs, mais il faut pouvoir savoir si la persistance du thème échoue pour un volume significatif d’utilisateurs.
- Compatibilité des composants : si votre appli intègre des widgets tiers, décidez s’ils héritent du thème ou restent fixes.
Une citation à coller sur votre moniteur : « L’espoir n’est pas une stratégie. » (idée paraphrasée, attribuée au général Gordon R. Sullivan) Le changement de thème exige la même posture : définissez les modes de défaillance, puis ingéniez pour les couvrir.
Blague #1 : Si votre sélecteur de thème provoque un effet flash à minuit, félicitations — vous avez inventé un nouveau type d’alerte on-call.
Concevoir le contrat : thèmes, sources et priorité
La plupart des sélecteurs de thème cassent parce que personne n’a formalisé le contrat. Vous voulez un arbre de décision simple et explicite que vous implémentez une fois puis oubliez. L’UI doit être une vue sur ce contrat, pas le contrat lui-même.
Le modèle de thème
Définissez deux concepts séparés :
- Préférence : ce que l’utilisateur a choisi :
system,light,dark,sepia… - Thème effectif : ce que vous appliquez maintenant : généralement
light,darkousepia.
« Système » est une préférence, pas un thème. Quand la préférence vaut system, vous calculez le thème effectif à partir de prefers-color-scheme. Si vous mélangez ces concepts, votre JS finira par réécrire la préférence utilisateur quand l’OS change — et les utilisateurs se fâchent parce qu’ils n’ont rien choisi.
Ordre de priorité (ne pas improviser)
- Préférence utilisateur explicite stockée côté client (localStorage, cookie, profil serveur) l’emporte.
- Préférence système via
prefers-color-schemesi la préférence utilisateur estsystemou non définie. - Valeur par défaut (généralement clair) si la détection échoue ou si le JS est désactivé.
État utile pour le débogage
J’aime stocker deux attributs dataset sur <html> :
data-theme=light/dark/sepia(effectif)data-theme-source=explicit/system/fallback
Ça paraît trivial jusqu’à ce que vous déboguiez un cas où le thème « se réinitialise au hasard » dans un navigateur durci pour la vie privée. L’attribut source vous dit si le stockage a fonctionné.
HTML UI : bouton + menu sans dette d’accessibilité
L’UI a deux fonctions : (1) permettre à l’utilisateur de changer, (2) communiquer l’état courant. Un bouton seul suffit pour deux thèmes, mais devient insuffisant quand on ajoute « système » ou « sépia ». Un menu déroulant est découvrable et extensible. Avoir les deux paraît redondant — jusqu’à ce que vous deviez livrer quelque chose qui fonctionne pour les utilisateurs clavier, les power users et ceux qui veulent juste que la page cesse de les éblouir.
Ce que nous livrons
- Un bouton Bascule qui inverse
lightetdark. Il ne force pas « system ». C’est une action rapide. - Un menu Thème qui propose
system,light,darketsepia.
Choix d’accessibilité
- Utilisez un vrai
<button>et un vrai<select>. Les contrôles natifs vous apportent beaucoup d’accessibilité gratuitement. - Étiquetez les contrôles avec
aria-labelou des labels visibles. Évitez les boutons icônes non étiquetés à moins d’aimer les rapports d’audit courroucés. - N’en faites pas trop avec des rôles ARIA pour des widgets simples. ARIA n’est pas un kit de bricolage ; c’est un outil tranchant.
Le HTML dans l’en-tête de cette page est l’implémentation de référence. Copiez-le. Changez les IDs si nécessaire. Gardez la sémantique.
Stratégie CSS : variables, color-scheme et valeurs par défaut sensées
La façon la plus simple de rendre le theming viable est d’utiliser des variables CSS pour tous les tokens de couleur et de les définir sur :root et [data-theme="…"]. Si votre CSS est plein de valeurs hexadécimales codées en dur disséminées dans les composants, vous n’avez pas de thèmes. Vous avez un incident futur.
Utilisez des tokens, pas des ambiances
Commencez avec un petit ensemble de tokens :
--bg,--fg--mutedpour le texte secondaire--cardpour les surfaces--border--link--focuspour les anneaux de focus
C’est l’ensemble minimal viable qui vous évite de rethematiser chaque composant manuellement.
color-scheme n’est pas optionnel
Les navigateurs modernes utilisent color-scheme pour décider comment peindre l’UI native (barres de défilement, contrôles de formulaire dans certains contextes) et pour optimiser le rendu. Si vous appliquez des couleurs sombres mais oubliez color-scheme: dark;, vous obtiendrez une « page sombre, champs lumineux » ou d’autres incohérences.
Dans le CSS ci-dessus, chaque thème définit color-scheme. C’est intentionnel.
Préférez les sélecteurs d’attribut sur <html>
Placez data-theme sur <html> (documentElement). Ça scope le thème à tout le document et fonctionne bien avec du contenu embarqué. Évitez de mettre des classes de thème sur <body> si vous avez des scripts qui remplacent le body ou si vous faites du rendu serveur avec des partials ; vous risquez des transitions étranges pendant l’hydratation.
Transitions : utilisez-les avec parcimonie
Les gens aiment les fondus. Les SRE aiment le comportement prévisible. Si vous ajoutez des transitions globales comme transition: background-color 250ms; sur *, vous finirez par casser quelque chose (chargeurs squelettes, graphiques). Si vous ajoutez des transitions, limitez-les à quelques éléments et respectez la préférence de réduction de mouvement :
- Utilisez
@media (prefers-reduced-motion: reduce)pour désactiver les transitions. - Ne transitionnez pas
color-scheme. Ce n’est pas le bon endroit.
Petit JS vraiment robuste (et qui évite le flash)
Il y a deux moments JavaScript qui comptent :
- Démarrage précoce : choisir le thème effectif avant le premier rendu pour éviter le flash.
- Interaction : mettre à jour le thème suite à une action utilisateur et persister la préférence.
Placement du script de démarrage
Mettez un petit script dans le <head> qui s’exécute avant l’application du CSS. Il doit :
- Tenter de lire la préférence sauvegardée depuis
localStorage. - Si la préférence enregistrée est
systemou absente, résoudre viaprefers-color-scheme. - Définir immédiatement
document.documentElement.dataset.theme. - Attraper les exceptions de stockage (oui, ça arrive) et retomber en sécurité.
Le script dans l’en-tête de ce document fait exactement cela. Il utilise try/catch parce que certains navigateurs lèvent lors d’accès au stockage en modes de vie privée, et parce que vous pourriez embarquer cette page dans un contexte qui refuse le stockage.
Script d’interaction (la partie que beaucoup oublient de durcir)
Ci-dessous le reste du JS. Il synchronise le menu déroulant, rend le bouton bascule opérationnel, écoute les changements de thème système quand la préférence est system, et persiste la préférence.
cr0x@server:~$ cat theme-switcher.js
(function(){
var storageKey = "theme.preference";
var root = document.documentElement;
var btn = document.getElementById("theme-toggle");
var sel = document.getElementById("theme-select");
if (!btn || !sel) return;
function getSystemTheme() {
var mql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
return (mql && mql.matches) ? "dark" : "light";
}
function getSavedPreference() {
try { return localStorage.getItem(storageKey); }
catch (e) { return null; }
}
function savePreference(pref) {
try { localStorage.setItem(storageKey, pref); }
catch (e) { /* ignore */ }
}
function applyTheme(pref) {
var effective = pref;
if (!effective || effective === "system") {
effective = getSystemTheme();
root.dataset.themeSource = "system";
} else {
root.dataset.themeSource = "explicit";
}
root.dataset.theme = effective;
sel.value = pref || "system";
btn.setAttribute("aria-label", "Toggle theme (currently " + effective + ")");
}
function toggleLightDark() {
var current = root.dataset.theme || "light";
var next = (current === "dark") ? "light" : "dark";
savePreference(next);
applyTheme(next);
}
// Initialize UI from saved preference (or system).
var pref = getSavedPreference() || "system";
applyTheme(pref);
btn.addEventListener("click", function(){
toggleLightDark();
});
sel.addEventListener("change", function(){
var pref = sel.value;
savePreference(pref);
applyTheme(pref);
});
// If user follows system, respond to system changes.
var mql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
if (mql && mql.addEventListener) {
mql.addEventListener("change", function(){
var pref = getSavedPreference() || sel.value || "system";
if (pref === "system") applyTheme("system");
});
} else if (mql && mql.addListener) {
// Older Safari
mql.addListener(function(){
var pref = getSavedPreference() || sel.value || "system";
if (pref === "system") applyTheme("system");
});
}
})();
Observations importantes :
- Nous stockons la préférence, pas le thème effectif. C’est ainsi que « system » garde son sens.
- Nous mettons à jour l’UI après l’application du thème. Cela évite un décalage où le menu indique « Système » alors que vous forcez le sombre.
- Nous écoutons les changements système seulement quand la préférence est system. Sinon vous écrasez un choix explicite de l’utilisateur, et c’est une façon rapide de déclencher un ticket d’un décideur.
Blague #2 : Un sélecteur de thème sans persistance, c’est comme une machine à café qui oublie « fort » chaque matin — fonctionnel techniquement, inacceptable émotionnellement.
Faits et contexte historique (oui, c’est pertinent)
Le changement de thème n’est pas nouveau, mais la plateforme navigateur autour a beaucoup évolué. Quelques faits utiles vous aident à prendre de meilleures décisions et éviter du code « cargo-cult ».
- Les premiers “thèmes” étaient souvent basés sur des images. Dans les années 2000, « skinner » signifiait souvent remplacer des images d’arrière-plan et des sprites. Ça avait l’air cool, et ça chargeait comme un camion.
- Le mode sombre au niveau système a rendu la préférence portable. Quand les OS ont introduit des réglages d’apparence globaux, les utilisateurs ont cessé de penser « cette appli a un thème sombre » et ont commencé à s’attendre à ce que tout suive leur appareil.
prefers-color-schemea changé l’attente par défaut. Une fois que les navigateurs ont exposé la préférence système via une media query, l’ignorer est devenu un problème d’accessibilité et de confort, pas seulement un choix stylistique.- Le « flash de contenu non thématisé » prédates le mode sombre. Le FOUC parlait originellement du chargement tardif du CSS montrant du HTML non stylé. Les flashes de thème sombre sont la variante moderne.
color-schemeest relativement récent et facile à manquer. Il existe parce que les contrôles de formulaire et les scrollbars ne sont pas purement pilotés par le CSS partout. Sans lui, vous obtenez des affordances incohérentes.- LocalStorage est synchrone. C’est pourquoi il est pratique pour le démarrage précoce, mais dangereux si vous faites de gros writes. Les lectures sont généralement OK ; les gros writes dans des chemins chauds ne le sont pas.
- Certains environnements lèvent sur l’accès au stockage. Les modes navigation privée, les webviews embarqués et des paramètres de vie privée stricts peuvent faire échouer
localStorageavec une exception au lieu de retourner null. - L’informatique d’entreprise peut imposer des politiques d’apparence. Une option « système » aligne votre appli avec les postes gérés. Sans elle, vous luttez contre la politique avec du CSS.
Trois mini-histoires d’entreprise depuis les tranchées du thème
Incident : la fausse hypothèse que « système » est un thème
Un tableau de bord interne de taille moyenne avait un menu de thème avec trois options : Light, Dark et System. Sous le capot, ils stockaient la valeur du menu dans localStorage et l’appliquaient comme classe sur <body>. Le CSS définissait des règles pour .light et .dark. Vous voyez le retournement venir.
Le premier jour, tout semblait OK parce que la plupart choisissaient Light ou Dark. Mais l’option « System » était populaire chez les utilisateurs qui se déplacent entre bureau et domicile. Quand ces utilisateurs sélectionnaient System, l’appli stockait system et appliquait la classe system. Le CSS ne la définissait pas, donc la page retombait sur des styles par défaut — principalement le mode clair, sauf pour des composants partiellement refactorisés pour utiliser des variables. Résultat : thème mixte. Manques de contraste. Boutons qui avaient l’air désactivés alors qu’ils ne l’étaient pas.
Les tickets de support ont décrit une « corruption aléatoire » de l’UI. Les ingénieurs ont d’abord suspecté la mise en cache ou des déploiements partiels parce que le bug ne se reproduisait pas systématiquement. En réalité il se reproduisait à condition que l’utilisateur choisisse « System ». L’hypothèse était le bug : traiter « system » comme un thème au lieu d’une préférence qui se résout en thème effectif.
La correction fut ennuyeuse et rapide : stocker la préférence séparément, calculer le thème effectif à l’exécution, et définir un seul attribut sur <html>. Ils ont aussi ajouté data-theme-source pour faciliter le diagnostic. La prochaine fois qu’on a crié « c’est aléatoire », ce ne l’était plus.
Optimisation qui s’est retournée contre eux : mise en cache trop agressive
Une équipe e‑commerce voulait éliminer le flash du mode sombre et l’a presque supprimé en rendant le thème côté serveur via un cookie. Bonne idée. Ils ont ajouté un reverse proxy et ont mis en cache le HTML pour les utilisateurs non authentifiés. Toujours bien, si on est prudent.
Ils n’ont pas été prudents. Ils ont mis en cache les réponses HTML sans varier selon le cookie de thème. La première requête après un miss peuplait le cache avec le HTML clair ou sombre selon qui passait en premier. Tout le monde recevait cette variante cachée. Les utilisateurs voyaient leur thème « changer au hasard » entre les visites, parce que le cache tournait selon l’expiration, pas selon la préférence.
Pire : le fichier CSS incluait des variables spécifiques au thème générées côté serveur. Le cache empoisonné n’affectait pas seulement le HTML ; il touchait aussi le payload CSS. L’équipe a accusé les navigateurs, puis les CDNs, puis la pleine lune. Pendant ce temps, les utilisateurs constataient des incohérences et pensaient que le site était instable.
Le rollback a été douloureux car la modification de cache touchait le budget performance. La solution finale : varier le cache par cookie quand présent, mais aussi garder un résolveur côté client robuste en backup. Et arrêter de générer du CSS spécifique au thème à la demande ; livrer du CSS statique avec des attributs data. L’« optimisation » était une taxe de stabilité jusqu’à changement d’architecture.
Ennuyeux mais correct : un petit script en tête qui a sauvé la situation
Une équipe finance a construit un outil de reporting interne utilisé sur grands écrans en bureau lumineux et sur portables en salles sombres. Ils étaient stricts sur l’accessibilité car des auditeurs étaient impliqués. Leur UI était rendue côté serveur, avec un soupçon de JS.
Quand ils ont ajouté le mode sombre, le premier prototype était « acceptable » mais avait le flash habituel au chargement. Ça ne se voyait pas beaucoup en dev parce que les machines dev sont rapides et locales. En production, avec la latence réelle et un script analytics injecté, le flash était très visible.
Au lieu de réécrire l’app ou d’importer une bibliothèque de theming, ils ont fait quelque chose d’inutilement simple : ils ont ajouté un script en tête de 20 lignes qui lit la préférence et définit data-theme avant que le CSS ne charge. Ils ont aussi écrit un test d’intégration qui charge la page avec une valeur localStorage pré‑remplie et vérifie que le premier rendu est déjà thématisé.
C’est tout. Pas de héros. Pas de refonte de plateforme. Les auditeurs ont arrêté de trouver des régressions de contraste parce que les tokens étaient centralisés. L’on-call a cessé de recevoir des tickets « mes yeux ». Rappel : la solution « ennuyeuse » est souvent la plus fiable.
Tâches pratiques : commandes, sortie attendue et décisions
Vous pouvez construire ça dans un codepen et l’appeler un jour. Ou vous pouvez le déployer dans un vrai environnement où build, cache, headers et régressions existent. Voici des tâches opérationnelles que je ferais (ou demanderais) avant de considérer la feature prête pour la production.
Tâche 1 : Vérifier que votre HTML définit data-theme avant le premier rendu (grep basique)
cr0x@server:~$ grep -n "document.documentElement.dataset.theme" -n index.html
42: document.documentElement.dataset.theme = theme;
48: document.documentElement.dataset.theme = "light";
Ce que ça signifie : Vous avez un setter précoce. S’il n’est que dans un bundle différé, vous aurez un flash.
Décision : Si le setter n’est pas dans le head ou s’exécute trop tard, déplacez-le dans un script inline en tête.
Tâche 2 : Confirmer que le script de tête s’exécute avant le CSS externe (vérification d’ordre)
cr0x@server:~$ awk 'NR<=80{print NR ":" $0}' index.html
1:
2:
3:
4:
5:
6: Theme Switcher UI That Doesn’t Betray You: Button, Dropdown, and Remembered Preference
...
23:
113:
114:
Ce que ça signifie : Dans cet exemple, le CSS est inline et le script de boot est après, ce qui reste acceptable car le script s’exécute immédiatement pendant le parsing, mais soyez volontaire. Avec du CSS externe, vous voulez le script de boot avant le chargement du CSS ou au moins avant le rendu.
Décision : Si vous utilisez des fichiers CSS externes, placez le script de boot au‑dessus des <link rel="stylesheet"> ou inlinez un CSS minimal et définissez le thème avant l’application complète du CSS.
Tâche 3 : Vérifier les exceptions de stockage dans un environnement verrouillé
cr0x@server:~$ node -e 'console.log("Simulate: localStorage may throw in some browsers; ensure try/catch exists")'
Simulate: localStorage may throw in some browsers; ensure try/catch exists
Ce que ça signifie : Cette « tâche » relève du processus : vous devez coder en supposant que le stockage peut échouer. Vous ne pouvez pas reproduire tous les modes de vie privée en CI.
Décision : Gardez le try/catch autour des lectures/écritures de stockage. Traitez l’absence de préférence comme « system ».
Tâche 4 : Valider que le bundle JS n’écrase pas accidentellement le thème précoce
cr0x@server:~$ grep -R --line-number "dataset.theme =" dist/ | head
dist/app.js:812:root.dataset.theme = effective;
Ce que ça signifie : Votre JS principal définit aussi le thème, ce qui est acceptable s’il utilise la même logique et la même clé de préférence. Ce n’est pas acceptable s’il default toujours sur clair.
Décision : Assurez-vous que le démarrage précoce et le code d’interaction partagent la même source de préférence et les mêmes règles de priorité.
Tâche 5 : Confirmer que votre CSS n’a pas de couleurs codées en dur qui cassent les thèmes
cr0x@server:~$ grep -R --line-number -E "#[0-9a-fA-F]{3,6}\b|rgb\(|hsl\(" src/styles | head
src/styles/components/buttons.css:12: border: 1px solid var(--border);
src/styles/components/layout.css:4: background: var(--bg);
Ce que ça signifie : Idéalement, le grep trouve surtout des usages de tokens. Si vous voyez des hex aléatoires, ils risquent fort d’être incorrects dans un thème.
Décision : Remplacez les couleurs composants par des variables. Conservez quelques accents de marque constants seulement si vous les avez testés dans tous les modes.
Tâche 6 : Linter pour transitions globales accidentelles
cr0x@server:~$ grep -R --line-number "transition:" src/styles | head
src/styles/base.css:88: transition: background-color 250ms ease, color 250ms ease;
Ce que ça signifie : Les transitions globales peuvent introduire du jank et des animations inattendues dans des graphiques ou des skeletons.
Décision : Si les transitions s’appliquent à de larges sous-arbres DOM, restreignez-les à quelques conteneurs ou retirez-les.
Tâche 7 : Vérifier que le HTML servi n’est pas mis en cache incorrectement quand on utilise des cookies
cr0x@server:~$ curl -I -H "Cookie: theme=dark" http://localhost:8080/ | egrep -i "cache-control|vary|set-cookie"
Cache-Control: public, max-age=300
Vary: Accept-Encoding
Ce que ça signifie : Si vous servez le thème via cookie et mettez en cache le HTML, vous devez probablement ajouter Vary: Cookie ou désactiver le cache pour le HTML personnalisé. La sortie ci‑dessus ne varie pas par cookie, donc un cache peut mélanger les thèmes.
Décision : Évitez le theming côté serveur pour les pages cachées, ou segmentez/variez correctement le cache.
Tâche 8 : Confirmer que votre CSP autorise le script inline en tête (ou prévoir un nonce)
cr0x@server:~$ curl -I http://localhost:8080/ | egrep -i "content-security-policy"
Content-Security-Policy: default-src 'self'; script-src 'self'
Ce que ça signifie : script-src 'self' bloque les scripts inline. Votre script de boot inline ne s’exécutera pas.
Décision : Ajoutez un nonce pour le script inline, ou chargez un petit script de boot externe avec une haute priorité. Sinon, acceptez un certain flash.
Tâche 9 : Valider le comportement de prefers-color-scheme dans des tests headless
cr0x@server:~$ node -e 'console.log("Headless browsers may default to light; set emulation if you rely on prefers-color-scheme in tests.")'
Headless browsers may default to light; set emulation if you rely on prefers-color-scheme in tests.
Ce que ça signifie : Vos CI headless peuvent default sur le mode clair. Les tests qui supposent le sombre par défaut échoueront de manière imprévisible.
Décision : En E2E, définissez explicitement la préférence (stockage ou émulation) et assert data-theme.
Tâche 10 : Mesurer si le flash de thème est visible (sniff rapide de perf)
cr0x@server:~$ google-chrome --headless --disable-gpu --dump-dom http://localhost:8080/ | head
<!doctype html><html lang="en" data-theme="light" data-theme-source="system">...
Ce que ça signifie : Le DOM dump montre que l’attribut thème est défini tôt. Ça ne prouve pas complètement l’absence de flash (le timing de paint est difficile en headless), mais c’est un contrôle de cohérence utile.
Décision : Si data-theme n’apparaît pas dans le DOM dump, votre boot précoce n’a pas été exécuté, probablement à cause de CSP ou d’un mauvais ordre.
Tâche 11 : Confirmer que les contrôles UI correspondent à la préférence stockée
cr0x@server:~$ node -e 'console.log("Manual check: select value should show system/light/dark/sepia, not always default. Verify after reload.")'
Manual check: select value should show system/light/dark/sepia, not always default. Verify after reload.
Ce que ça signifie : Si le menu déroulant se réinitialise visuellement à chaque fois, les utilisateurs penseront que le réglage n’a pas été sauvegardé même si c’est le cas.
Décision : Assurez-vous de définir select.value depuis la préférence stockée, pas depuis le thème effectif.
Tâche 12 : Vérifier que les attributs thème ne sont pas retirés par des sanitizers HTML
cr0x@server:~$ curl -s http://localhost:8080/ | head -n 3
Ce que ça signifie : Certains moteurs de template ou santizers suppriment des attributs inconnus. Si data-theme disparaît, le CSS ne s’appliquera pas comme prévu.
Décision : Si les attributs sont supprimés, appliquez le thème via une classe ou configurez le sanitiseur/moteur de templates pour autoriser les data-*.
Tâche 13 : Vérifier que vos widgets tiers n’ont pas de couleurs codées en dur
cr0x@server:~$ grep -R --line-number "style=" public/widgets | head
public/widgets/legacy-chat.html:17:<div style="background:#fff;color:#000">Chat</div>
Ce que ça signifie : Les styles inline ignorent votre système de tokens. En mode sombre, vous vous retrouverez avec des boîtes claires incrustées dans une page sombre.
Décision : Refactorez le styling des widgets pour utiliser des variables, ou isolez-les visuellement (les encadrer comme « toujours clair ») pour que ce soit intentionnel.
Tâche 14 : Confirmer que le serveur ne compresse/altère pas les scripts inline au point de les casser
cr0x@server:~$ curl -s -D - http://localhost:8080/ -o /dev/null | egrep -i "content-encoding|content-type"
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Ce que ça signifie : La compression est acceptable. Ce que vous recherchez, ce sont des réécritures inattendues (certaines solutions d’optimisation réécrivent des scripts inline).
Décision : Si vous utilisez un middleware de réécriture HTML, excluez le script de boot de la transformation ou déplacez-le dans un fichier statique.
Guide de diagnostic rapide
Quand le changement de thème casse, les gens décrivent des symptômes comme « aléatoire », « scintille » ou « n’ignore pas mon réglage ». Ne poursuivez pas les impressions. Suivez une séquence de triage serrée pour atteindre la cause racine vite.
Première étape : déterminer si le problème vient de la persistance, de la résolution ou du timing de rendu
- Vérification de persistance : Après avoir choisi Sombre, rechargez. Reste‑t‑il sombre ? Sinon, le stockage n’est pas lu ou écrit.
- Vérification de résolution : Si la préférence est « Système », le changement d’OS modifie‑t‑il le thème ? Sinon, l’écoute de la media query est manquante ou cassée.
- Vérification du timing de rendu : Le bon thème finit par s’appliquer mais vous voyez d’abord un flash ? Alors le script de boot est trop tardif ou bloqué par la CSP.
Deuxième étape : inspecter la source de vérité dans le DOM
- Regardez
<html data-theme="..." data-theme-source="...">. Sidata-theme-sourcevautfallback, le stockage a probablement levé une exception. - Confirmez que la valeur du menu reflète la préférence, pas le thème effectif.
Troisième étape : vérifier cache et politiques interférentes
- Si vous utilisez des cookies pour le thème côté serveur, vérifiez les headers de cache et le
Vary. - Vérifiez la CSP. Les scripts de boot inline meurent souvent ici.
- Vérifiez si une politique d’entreprise désactive le stockage persistant pour le site.
La question clé
Le goulot habituel n’est pas le CPU. C’est l’ordre : le navigateur peint avant que votre décision de thème ne s’exécute. Réglez ça d’abord. Ensuite, cherchez l’élégance.
Erreurs fréquentes : symptôme → cause → correctif
1) Symptôme : « Le mode sombre clignote en blanc au rechargement »
Cause : Le thème est appliqué après le premier rendu (bundle JS différé, ou s’exécute après le CSS).
Correctif : Inline un script minimal en tête pour définir data-theme avant le rendu ; assurez‑vous que la CSP l’autorise ou utilisez un nonce/fichier de boot statique.
2) Symptôme : « Je choisis Système et l’UI est à moitié thématisée »
Cause : Traiter system comme une classe de thème, au lieu de le résoudre en thème effectif.
Correctif : Stockez la préférence (system) mais appliquez le thème effectif (light/dark) sur data-theme. Ne créez pas de .system CSS sauf si c’est voulu.
3) Symptôme : « Le réglage ne persiste pas, ne fonctionne que jusqu’à la fermeture de l’onglet »
Cause : Utiliser sessionStorage par inadvertance, ou les écritures de stockage échouent à cause d’exceptions.
Correctif : Utilisez localStorage avec try/catch, ou fallback sur un cookie. Gardez l’app utilisable si la persistance échoue.
4) Symptôme : « Le menu indique Système mais la page est forcée en Sombre »
Cause : L’état de l’UI est dérivé du thème effectif plutôt que de la préférence stockée.
Correctif : Définissez toujours la valeur du menu depuis la préférence ; calculez le thème effectif séparément.
5) Symptôme : « Le thème change tout seul quand l’OS change alors que j’ai choisi Sombre »
Cause : Écoute des changements de prefers-color-scheme sans condition et réapplication du comportement « system ».
Correctif : Ne réagissez aux changements système que si la préférence est system.
6) Symptôme : « Les inputs et scrollbars restent clairs en thème sombre »
Cause : Omission de la déclaration color-scheme pour le thème sombre.
Correctif : Définissez color-scheme: dark; dans votre sélecteur de thème sombre.
7) Symptôme : « Les utilisateurs signalent un thème aléatoire, mais seulement en production »
Cause : Cache mélangeant des variantes de thème (theming basé cookie + cache partagé), ou CSP bloquant le script de boot.
Correctif : Corrigez le cache (varier/désactiver pour le HTML personnalisé) et assurez-vous que la CSP permet le bootstrap du thème.
8) Symptôme : « Le basculement de thème fonctionne, mais la page devient lente »
Cause : Grosses mises à jour du DOM dues à des transitions ou recalculs ; parfois causé par l’application du thème à de nombreux nœuds individuellement.
Correctif : Appliquez le thème à la racine (<html>) uniquement. Évitez les transitions globales. Gardez un petit ensemble de tokens.
Listes de contrôle / plan étape par étape
Étape par étape : livrer un sélecteur de thème fiable
- Définir les préférences : Décidez des valeurs autorisées (
system,light,dark, options facultatives). - Définir les tokens : Choisissez un petit ensemble de variables CSS que tous les composants utilisent.
- Implémenter les sélecteurs de thème :
:rootpour le défaut,[data-theme="dark"]etc. Gardez‑les sur<html>. - Ajouter
color-scheme: Le thème clair définitlight, le thème sombre définitdark. - Écrire le script de boot en tête : Lire la préférence (try/catch), résoudre la préférence système, définir
data-theme. - Ajouter les contrôles UI : Bouton et select natifs, étiquetés, accessibles au clavier.
- Écrire le script d’interaction : Changer la préférence, persister, appliquer le thème effectif, synchroniser l’UI.
- Gérer les changements système : Écouter
prefers-color-schemeuniquement quand la préférence estsystem. - Tester avec stockage bloqué : Confirmer que la page reste lisible et ne casse pas d’autres scripts.
- Tester la CSP : Assurer que votre script inline de boot est autorisé (nonce) ou le déplacer en fichier statique.
- Tester le comportement de cache : Si le theming est côté serveur pour un chemin, confirmer la segmentation du cache par préférence.
- Lancer et surveiller : Ajouter un logging léger pour les échecs de persistance si votre environnement le permet (compter les exceptions sans capturer de données utilisateur).
Checklist pré-lancement (ce que je ferais avant d’activer par défaut)
- La page charge avec le bon thème quand la préférence est définie (pas de flash visible dans un navigateur réel).
- Les états du menu et du bouton reflètent toujours la préférence/le thème effectif.
- Avec le JS désactivé, la page reste lisible et les contrôles ne trompent pas l’utilisateur.
- Avec le stockage bloqué, la sélection de thème fonctionne pour la session (même si non persistée) et ne casse rien.
- Les vérifications de contraste passent pour le texte principal, le texte atténué, les boutons et les focus dans tous les thèmes.
- Pas de transitions globales provoquant des animations indésirables.
- La CSP est compatible avec l’approche de boot.
FAQ
1) Dois‑je stocker le thème effectif ou la préférence ?
Stockez la préférence (system/light/dark). Calculez le thème effectif à l’exécution. Sinon « System » perd son sens et vous risquez d’écraser l’utilisateur.
2) Pourquoi ne pas se contenter de prefers-color-scheme et supprimer l’UI ?
Parce que les utilisateurs veulent du contrôle. De plus, les postes d’entreprise ont souvent des réglages système qui ne correspondent pas au confort personnel dans certaines applications. Donnez‑leur un override.
3) Est‑ce que localStorage est sûr pour ça ?
C’est approprié pour une petite chaîne. Le vrai problème est qu’il peut lever ou être indisponible dans des modes de vie privée. Encadrez l’accès avec try/catch et dégradez gracieusement.
4) Pourquoi mettre le script de boot inline dans le head ?
Pour éviter le flash du mauvais thème. Les scripts externes chargent plus tard et peuvent être retardés ou bloqués. Le code inline dans le head s’exécute pendant le parsing et peut définir data-theme avant le paint.
5) Ma CSP bloque les scripts inline. Que faire ?
Utilisez un nonce pour le script inline, ou servez un petit script externe « theme boot » avec une priorité élevée. Si vous ne pouvez ni l’un ni l’autre, acceptez un certain flash et minimisez‑le avec des valeurs par défaut raisonnables.
6) Le bouton bascule doit‑il cycler System → Clair → Sombre ?
Non. Laissez le bouton comme un flip rapide Clair/Sombre. Mettez System (et autres thèmes) dans le menu. Les utilisateurs comprennent vite cette séparation et ça évite des états bizarres.
7) Dois‑je écouter les changements de thème système ?
Seulement si la préférence utilisateur est system. Sinon c’est intrusif. N’oubliez pas que Safari ancien utilise addListener au lieu de addEventListener sur les media queries.
8) Où attacher l’attribut thème, sur <html> ou <body> ?
<html>. C’est la racine du document, ça réduit les bizarreries pendant l’hydratation ou le remplacement du body. C’est aussi cohérent avec le comportement de color-scheme.
9) Comment éviter de réécrire des tonnes de CSS ?
Utilisez des variables CSS comme tokens et faites en sorte que les composants dépendent uniquement des tokens. Les définitions de thème ne sont alors que des ensembles de tokens. C’est la seule approche qui reste maintenable après le premier sprint.
10) Puis‑je ajouter un thème « haute contraste » ?
Oui, et vous devriez y penser si votre appli est utilisée longtemps. Traitez‑le comme n’importe quel autre thème : définissez des tokens, ajoutez l’option et testez soigneusement le contraste et les indicateurs de focus.
Conclusion : prochaines étapes concrètes
Un sélecteur de thème est une petite fonctionnalité avec un rayon d’impact étonnamment large. Bien fait, il disparaît — les utilisateurs gagnent en confort, l’accessibilité s’améliore et votre UI cesse de combattre l’OS. Mal fait, il devient un générateur d’incidents : tickets sur les scintillements, rapports de « comportement aléatoire » et perte progressive de confiance.
Prochaines étapes :
- Implémentez la structure CSS basée sur tokens (
:root+[data-theme]) et définissezcolor-schemepar thème. - Ajoutez le script de boot en tête et vérifiez qu’il s’exécute avec votre CSP et votre configuration de cache.
- Raccordez le menu pour qu’il stocke la préférence, pas le thème effectif, et conservez le bouton comme flip rapide clair/sombre.
- Exécutez le guide de diagnostic rapide une fois — volontairement — pour reconnaître les modes de défaillance avant que les utilisateurs ne les trouvent.