Présentation de base sur la création d'un composant de bouton de thème adaptatif et accessible.
Dans cet article, je vais vous expliquer comment créer un composant de bascule de thème clair et sombre. Tester la fonctionnalité
Si vous préférez regarder une vidéo, voici une version YouTube de cet article:
Présentation
Un site Web peut fournir des paramètres permettant de contrôler le jeu de couleurs au lieu de s'appuyer entièrement sur les préférences du système. Cela signifie que les utilisateurs peuvent naviguer dans un mode autre que celui de leurs préférences système. Par exemple, le système d'un utilisateur utilise un thème clair, mais il préfère que le site Web s'affiche avec un thème sombre.
Plusieurs aspects d'ingénierie Web doivent être pris en compte lors de la création de cette fonctionnalité. Par exemple, le navigateur doit être informé de la préférence dès que possible pour éviter les clignotements de couleur de la page. Le contrôle doit d'abord se synchroniser avec le système, puis autoriser les exceptions stockées côté client.
Annoter
Un <button>
doit être utilisé pour le bouton d'activation/de désactivation, car vous bénéficiez alors des événements et des fonctionnalités d'interaction fournis par le navigateur, tels que les événements de clic et la possibilité de sélection.
Bouton
Le bouton a besoin d'une classe à utiliser à partir de CSS et d'un ID à utiliser à partir de JavaScript.
De plus, comme le contenu du bouton est une icône plutôt qu'un texte, ajoutez un attribut title pour fournir des informations sur l'objectif du bouton. Enfin, ajoutez un [aria-label]
pour conserver l'état du bouton d'icône afin que les lecteurs d'écran puissent partager l'état du thème avec les personnes malvoyantes.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
>
…
</button>
aria-label
et aria-live
courtois
Pour indiquer aux lecteurs d'écran que les modifications apportées à aria-label
doivent être annoncées, ajoutez aria-live="polite"
au bouton.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
…
</button>
Cet ajout de balisage indique aux lecteurs d'écran d'indiquer poliment à l'utilisateur ce qui a changé, au lieu de aria-live="assertive"
. Dans le cas de ce bouton, il annoncera "light" (clair) ou "dark" (sombre) en fonction de ce que aria-label
est devenu.
Icône SVG (Scalable Vector Graphics)
Le SVG permet de créer des formes évolutives et de haute qualité avec un balisage minimal. L'interaction avec le bouton peut déclencher de nouveaux états visuels pour les vecteurs, ce qui fait du SVG un format idéal pour les icônes.
Le balisage SVG suivant se trouve dans <button>
:
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
…
</svg>
aria-hidden
a été ajouté à l'élément SVG pour que les lecteurs d'écran sachent l'ignorer, car il est marqué comme élément de présentation. C'est idéal pour les décorations visuelles, comme l'icône dans un bouton. En plus de l'attribut viewBox
obligatoire sur l'élément, ajoutez la hauteur et la largeur pour les mêmes raisons que les tailles en ligne des images.
Soleil
Le graphique du soleil se compose d'un cercle et de lignes pour lesquelles le SVG propose des formes pratiques. Le <circle>
est centré en définissant les propriétés cx
et cy
sur 12, soit la moitié de la taille de la fenêtre d'affichage (24), puis en définissant un rayon (r
) de 6
, qui définit la taille.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>
De plus, la propriété mask pointe vers un ID d'élément SVG, que vous allez créer ensuite, et enfin une couleur de remplissage qui correspond à la couleur du texte de la page avec currentColor
.
Les rayons du soleil
Ensuite, les lignes de rayons du soleil sont ajoutées juste en dessous du cercle, dans un groupe d'éléments <g>
.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<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" />
</g>
</svg>
Cette fois, au lieu que la valeur de fill soit currentColor
, le trait de chaque ligne est défini. Les lignes et les cercles créent un beau soleil avec des rayons.
La Lune
Pour créer l'illusion d'une transition fluide entre la lumière (soleil) et l'obscurité (lune), la lune est une augmentation de l'icône du soleil, à l'aide d'un masque SVG.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
…
</g>
<mask class="moon" id="moon-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
</svg>
Les masques avec SVG sont puissants, car ils permettent d'utiliser les couleurs blanche et noire pour supprimer ou inclure des parties d'un autre graphique. L'icône du soleil sera éclipsée par une forme de lune <circle>
avec un masque SVG, simplement en déplaçant une forme de cercle dans et hors d'une zone de masque.
Que se passe-t-il si le CSS ne se charge pas ?
Il peut être utile de tester votre SVG comme si le CSS ne s'était pas chargé pour vous assurer que le résultat n'est pas trop volumineux ni ne pose de problèmes de mise en page. Les attributs de hauteur et de largeur intégrés au SVG, ainsi que l'utilisation de currentColor
, fournissent des règles de style minimales que le navigateur doit utiliser si le CSS ne se charge pas. Cela permet d'obtenir de bons styles défensifs contre les turbulences du réseau.
Mise en page
Le composant de bouton de sélection du thème a une petite surface. Vous n'avez donc pas besoin de grille ni de flexbox pour la mise en page. À la place, le positionnement SVG et les transformations CSS sont utilisés.
Styles
.theme-toggle
styles
L'élément <button>
est le conteneur des formes et des styles d'icône. Ce contexte parent contiendra des couleurs et des tailles adaptatives à transmettre au SVG.
La première tâche consiste à faire du bouton un cercle et à supprimer les styles de bouton par défaut:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
}
Ajoutez ensuite des styles d'interaction. Ajoutez un style de curseur pour les utilisateurs de la souris. Ajoutez touch-action: manipulation
pour une expérience tactile rapide.
Supprimez la mise en surbrillance semi-transparente qu'iOS applique aux boutons. Enfin, laissez un peu d'espace entre le contour de l'état de focus et le bord de l'élément:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
}
Le SVG à l'intérieur du bouton nécessite également des styles. Le SVG doit correspondre à la taille du bouton et, pour un rendu visuel plus doux, arrondir les extrémités des lignes:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
& > svg {
inline-size: 100%;
block-size: 100%;
stroke-linecap: round;
}
}
Dimensionnement adaptatif avec la requête média hover
La taille du bouton d'icône est un peu petite à 2rem
, ce qui convient aux utilisateurs de souris, mais peut être difficile pour un pointeur grossier comme un doigt. Assurez-vous que le bouton respecte de nombreuses consignes relatives à la taille tactile à l'aide d'une requête multimédia au survol pour spécifier une augmentation de taille.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Styles SVG du soleil et de la lune
Le bouton contient les aspects interactifs du composant de bouton de thème, tandis que le SVG interne contient les aspects visuels et animés. C'est là que l'icône peut être embellie et donner vie.
Thème clair
Pour que les animations de mise à l'échelle et de rotation se produisent à partir du centre des formes SVG, définissez leur transform-origin: center center
. Les couleurs adaptatives fournies par le bouton sont utilisées ici par les formes. La lune et le soleil utilisent les boutons var(--icon-fill)
et var(--icon-fill-hover)
pour leur remplissage, tandis que les rayons du soleil utilisent les variables pour le trait.
.sun-and-moon {
& > :is(.moon, .sun, .sun-beams) {
transform-origin: center center;
}
& > :is(.moon, .sun) {
fill: var(--icon-fill);
@nest .theme-toggle:is(:hover, :focus-visible) > & {
fill: var(--icon-fill-hover);
}
}
& > .sun-beams {
stroke: var(--icon-fill);
stroke-width: 2px;
@nest .theme-toggle:is(:hover, :focus-visible) & {
stroke: var(--icon-fill-hover);
}
}
}
Thème sombre
Les styles de lune doivent supprimer les rayons du soleil, mettre à l'échelle le cercle du soleil et déplacer le masque du cercle.
.sun-and-moon {
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
}
& > .sun-beams {
opacity: 0;
}
& > .moon > circle {
transform: translateX(-7px);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
}
}
}
}
Notez que le thème sombre ne comporte aucune transition ni modification de couleur. Le composant de bouton parent est propriétaire des couleurs, qui sont déjà adaptatives dans un contexte sombre et clair. Les informations de transition doivent se trouver derrière la requête multimédia de préférence de mouvement de l'utilisateur.
Animation
À ce stade, le bouton doit être fonctionnel et avec état, mais sans transition. Les sections suivantes visent à définir comment et quoi pour les transitions.
Partager des requêtes multimédias et importer des assouplissements
Pour faciliter l'ajout de transitions et d'animations en fonction des préférences de mouvement du système d'exploitation d'un utilisateur, le plug-in Custom Media du PostCSS permet d'utiliser la syntaxe de la spécification CSS pour les variables de requêtes multimédias:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
Pour des effets CSS uniques et faciles à utiliser, importez la partie easings d'Open Props:
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
Soleil
Les transitions du soleil seront plus ludiques que celles de la lune, en obtenant cet effet avec des amortissements rebondissants. Les rayons du soleil doivent rebondir légèrement à mesure qu'ils tournent, et le centre du soleil doit rebondir légèrement à mesure qu'il se met à l'échelle.
Les styles par défaut (thème clair) définissent les transitions, tandis que les styles du thème sombre définissent les personnalisations pour la transition vers le thème clair:
.sun-and-moon {
@media (--motionOK) {
& > .sun {
transition: transform .5s var(--ease-elastic-3);
}
& > .sun-beams {
transition:
transform .5s var(--ease-elastic-4),
opacity .5s var(--ease-3)
;
}
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
transition-timing-function: var(--ease-3);
transition-duration: .25s;
}
& > .sun-beams {
transform: rotateZ(-25deg);
transition-duration: .15s;
}
}
}
}
Dans le panneau Animation des outils pour les développeurs Chrome, vous trouverez une chronologie des transitions d'animation. Vous pouvez inspecter la durée de l'animation totale, des éléments et du timing d'atténuation.
La Lune
Les positions de la lune claire et sombre sont déjà définies. Ajoutez des styles de transition dans la requête multimédia --motionOK
pour la donner vie tout en respectant les préférences de mouvement de l'utilisateur.
Le timing avec le délai et la durée est essentiel pour que cette transition soit fluide. Si le soleil est éclipsé trop tôt, par exemple, la transition ne semble pas orchestrée ni ludique, mais chaotique.
.sun-and-moon {
@media (--motionOK) {
& .moon > circle {
transform: translateX(-7px);
transition: transform .25s var(--ease-out-5);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
transition: cx .25s var(--ease-out-5);
}
}
@nest [data-theme="dark"] & {
& > .moon > circle {
transition-delay: .25s;
transition-duration: .5s;
}
}
}
}
Préfère les mouvements réduits
Dans la plupart des défis d'interface utilisateur, j'essaie de conserver une animation, comme les fondus croisés d'opacité, pour les utilisateurs qui préfèrent réduire le mouvement. Toutefois, ce composant était plus adapté aux changements d'état instantanés.
JavaScript
JavaScript a beaucoup de travail à effectuer dans ce composant, de la gestion des informations ARIA pour les lecteurs d'écran à l'obtention et à la définition de valeurs à partir du stockage local.
Expérience de chargement de la page
Il était important qu'aucun clignotement de couleur ne se produise lors du chargement de la page. Si un utilisateur avec un jeu de couleurs sombres indique qu'il préfère la lumière avec ce composant, puis qu'il recharge la page, la page est d'abord sombre, puis elle passe en mode clair.
Pour éviter cela, nous avons exécuté une petite quantité de code JavaScript bloquant dans le but de définir l'attribut HTML data-theme
le plus tôt possible.
<script src="./theme-toggle.js"></script>
Pour ce faire, une balise <script>
simple dans le document <head>
est chargée en premier, avant tout balisage CSS ou <body>
. Lorsque le navigateur rencontre un script non marqué comme tel, il exécute le code avant le reste du code HTML. En utilisant ce moment de blocage avec parcimonie, il est possible de définir l'attribut HTML avant que le CSS principal ne peigne la page, ce qui évite un flash ou des couleurs.
Le code JavaScript vérifie d'abord les préférences de l'utilisateur dans le stockage local, puis vérifie les préférences du système si rien n'est trouvé dans le stockage:
const storageKey = 'theme-preference'
const getColorPreference = () => {
if (localStorage.getItem(storageKey))
return localStorage.getItem(storageKey)
else
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
Une fonction permettant de définir les préférences de l'utilisateur dans le stockage local est ensuite analysée:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
suivie d'une fonction permettant de modifier le document avec les préférences.
const reflectPreference = () => {
document.firstElementChild
.setAttribute('data-theme', theme.value)
document
.querySelector('#theme-toggle')
?.setAttribute('aria-label', theme.value)
}
À ce stade, il est important de noter l'état d'analyse du document HTML. Le navigateur ne connaît pas encore le bouton "#theme-toggle", car la balise <head>
n'a pas été entièrement analysée. Toutefois, le navigateur dispose d'un document.firstElementChild
, également appelé balise <html>
. La fonction tente de les définir pour les synchroniser, mais lors de la première exécution, elle ne peut définir que la balise HTML. querySelector
ne trouve rien au début, et l'opérateur de chaînage facultatif garantit qu'aucune erreur de syntaxe ne se produit lorsqu'il n'est pas trouvé et que la fonction setAttribute est tentée d'être appelée.
Ensuite, cette fonction reflectPreference()
est immédiatement appelée afin que le document HTML définisse son attribut data-theme
:
reflectPreference()
Le bouton a toujours besoin de l'attribut. Attendez donc l'événement de chargement de la page. Vous pourrez alors effectuer des requêtes, ajouter des écouteurs et définir des attributs:
window.onload = () => {
// set on load so screen readers can get the latest value on the button
reflectPreference()
// now this script can find and listen for clicks on the control
document
.querySelector('#theme-toggle')
.addEventListener('click', onClick)
}
Expérience de basculement
Lorsque l'utilisateur clique sur le bouton, le thème doit être remplacé, dans la mémoire JavaScript et dans le document. La valeur du thème actuel doit être inspectée et une décision doit être prise concernant son nouvel état. Une fois le nouvel état défini, enregistrez-le et mettez à jour le document:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
Synchronisation avec le système
La synchronisation avec la préférence système à mesure qu'elle change est unique à ce changement de thème. Si un utilisateur modifie ses préférences système alors qu'une page et ce composant sont visibles, le bouton de sélection du thème change pour correspondre à la nouvelle préférence de l'utilisateur, comme s'il avait interagi avec le bouton de sélection du thème en même temps que le bouton de sélection du système.
Pour ce faire, utilisez JavaScript et un événement matchMedia
qui écoute les modifications apportées à une requête multimédia:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
Conclusion
Maintenant que vous savez comment j'ai fait, comment procéderiez-vous ? 🙂
Diversifions nos approches et découvrons toutes les façons de créer sur le Web. Créez une démo, tweetez-moi des liens et je les ajouterai à la section "Remix de la communauté" ci-dessous.
Remix de la communauté
- @NathanG sur Codepen avec Vue
- @ShadowShahriar sur Codepen
- @tomayac en tant qu'élément personnalisé
- @bramus avec JavaScript standard
- @JoshWComeau avec react