Créer un composant de changement de thème

Présentation de base de la création d'un composant de changement de thème adaptatif et accessible.

Dans cet article, je souhaite partager une réflexion sur la manière de créer un composant de changement de thème clair et sombre. Tester la fonctionnalité

Démo a été augmentée pour une meilleure visibilité.

Si vous préférez les vidéos, 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 la préférence système. Cela signifie que les utilisateurs peuvent naviguer dans un mode autre que celui défini dans leurs préférences système. Par exemple, le système d'un utilisateur est en thème clair, mais il préfère que le site Web s'affiche en thème sombre.

Plusieurs aspects de l'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 le plus tôt possible pour éviter les flashs de couleur sur la page, et le contrôle doit d'abord se synchroniser avec le système, puis autoriser les exceptions stockées côté client.

Le diagramme montre un aperçu des événements d'interaction de chargement de page et de document JavaScript pour indiquer qu'il existe quatre façons de définir le thème.

Annoter

Un <button> doit être utilisé pour le bouton bascule, car vous bénéficiez alors des événements et fonctionnalités d'interaction fournis par le navigateur, tels que les événements de clic et la possibilité de mise au point.

Bouton

Le bouton a besoin d'une classe pour être utilisé à partir de CSS et d'un ID pour être utilisé à 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. Les lecteurs d'écran pourront ainsi 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 poli

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 de dire à l'utilisateur ce qui a changé, de manière polie, au lieu de aria-live="assertive". Dans le cas de ce bouton, il annoncera "clair" ou "sombre" selon ce que aria-label est devenu.

Icône SVG (Scalable Vector Graphics)

Le format SVG permet de créer des formes évolutives 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 excellent format pour les icônes.

Le balisage SVG suivant doit être placé 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 à l'intérieur d'un bouton. En plus de l'attribut viewBox requis sur l'élément, ajoutez la hauteur et la largeur pour des raisons similaires à celles pour lesquelles les images doivent obtenir des tailles intégrées.

Soleil

Icône du soleil avec des rayons estompés et une flèche rose vif pointant vers le cercle au centre.

Le graphique du soleil se compose d'un cercle et de lignes, pour lesquels SVG propose des formes. 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 lui attribuant 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é de masque pointe vers l'ID d'un élément SVG, que vous allez créer ensuite. Enfin, une couleur de remplissage correspondant à la couleur du texte de la page est attribuée avec currentColor.

Les rayons du soleil

Icône du soleil avec le centre du soleil estompé et une flèche rose vif pointant vers les rayons du soleil.

Ensuite, les lignes du rayon de soleil sont ajoutées juste en dessous du cercle, à l'intérieur d'un élément de groupe <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 stroke de chaque ligne est défini. Les lignes et les formes circulaires créent un joli 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>
Graphique avec trois couches verticales pour illustrer le fonctionnement du masquage. Le calque supérieur est un carré blanc avec un cercle noir. La couche intermédiaire est l&#39;icône du soleil.
Le calque inférieur est intitulé &quot;Résultat&quot; et affiche l&#39;icône du soleil avec une découpe à l&#39;emplacement du cercle noir du calque supérieur.

Les masques avec SVG sont puissants. Ils permettent aux couleurs blanche et noire de supprimer ou d'inclure des parties d'un autre élément 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 à l'intérieur et à l'extérieur d'une zone de masque.

Que se passe-t-il si le CSS ne se charge pas ?

Capture d&#39;écran d&#39;un bouton de navigateur simple avec l&#39;icône du soleil à l&#39;intérieur.

Il peut être utile de tester votre SVG comme si le CSS ne se chargeait pas pour vous assurer que le résultat n'est pas trop volumineux ou ne provoque pas 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 peut utiliser si le CSS ne se charge pas. Cela permet d'adopter des styles défensifs intéressants contre les turbulences du réseau.

Disposition

Le composant de changement de thème a une petite surface, vous n'avez donc pas besoin de grille ni de boîte flexible 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ônes. Ce contexte parent contiendra les couleurs et les tailles adaptatives à transmettre au SVG.

La première tâche consiste à transformer le bouton en 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 souris. Ajoutez touch-action: manipulation pour une expérience tactile réactive. Supprime la mise en surbrillance semi-transparente qu'iOS applique aux boutons. Enfin, donnez un peu d'espace à la bordure de l'état de sélection par rapport au 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 a également besoin de styles. Le SVG doit correspondre à la taille du bouton et, pour une apparence plus douce, 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 en forme d'icône est un peu petite (2rem). Cela ne pose pas de problème pour les utilisateurs de souris, mais peut être difficile à utiliser avec un pointeur grossier comme un doigt. Respectez les nombreuses consignes concernant la taille des zones tactiles en utilisant une requête média de survol pour spécifier une augmentation de la 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 changement de thème, tandis que SVG à l'intérieur contient les aspects visuels et animés. C'est ici que l'icône peut être embellie et prendre vie.

Thème clair

ALT_TEXT_HERE

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 fournis 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

ALT_TEXT_HERE

Les styles de lune doivent supprimer les rayons du soleil, agrandir 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 modification ni transition de couleur. Le composant bouton parent possède les couleurs, qui sont déjà adaptatives dans un contexte sombre et clair. Les informations de transition doivent se trouver derrière une requête média de préférence de mouvement de l'utilisateur.

Animation

Le bouton doit être fonctionnel et avec état, mais sans transitions pour le moment. Les sections suivantes expliquent comment et ce que sont les transitions.

Partager des requêtes média et importer des courbes d'accélération

Pour faciliter l'ajout de transitions et d'animations derrière les préférences de mouvement du système d'exploitation d'un utilisateur, le plug-in PostCSS Custom Media permet d'utiliser la syntaxe de la spécification CSS provisoire pour les variables de requête média :

@custom-media --motionOK (prefers-reduced-motion: no-preference);

/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}

Pour des fonctions d'accélération 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 utilisant des courbes d'accélération élastiques. Les rayons du soleil doivent rebondir légèrement lorsqu'ils tournent, et le centre du soleil doit rebondir légèrement lorsqu'il est mis à l'échelle.

Les styles par défaut (thème clair) définissent les transitions, et 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 un calendrier des transitions d'animation. Vous pouvez inspecter la durée de l'animation totale, des éléments et du minutage de l'interpolation.

Transition du clair au sombre
Transition du sombre au clair

La Lune

Les positions claires et sombres de la lune sont déjà définies. Ajoutez des styles de transition à l'intérieur de la requête média --motionOK pour l'animer 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 se fasse en douceur. 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;
      }
    }
  }
}
Transition du clair au sombre
Transition du sombre au clair

Mouvements réduits

Dans la plupart des défis d'interface utilisateur graphique, j'essaie de conserver une animation, comme des fondues enchaînées d'opacité, pour les utilisateurs qui préfèrent les mouvements réduits. Toutefois, ce composant semblait mieux fonctionner avec des changements d'état instantanés.

JavaScript

Ce composant nécessite beaucoup de travail pour JavaScript, de la gestion des informations ARIA pour les lecteurs d'écran à l'obtention et à la définition des valeurs à partir du stockage local.

Expérience de chargement de 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 le jeu de couleurs claires avec ce composant, puis recharge la page, la page sera d'abord sombre, puis passera à la couleur claire. Pour éviter cela, il fallait exécuter une petite quantité de 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 <head> du document est chargée en premier, avant tout balisage CSS ou <body>. Lorsque le navigateur rencontre un script non marqué comme celui-ci, 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 JavaScript vérifie d'abord la préférence de l'utilisateur dans le stockage local et, en cas d'échec, vérifie la préférence 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 la préférence de l'utilisateur dans le stockage local est ensuite analysée :

const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}

suivi d'une fonction permettant de modifier le document en fonction des préférences.

const reflectPreference = () => {
  document.firstElementChild
    .setAttribute('data-theme', theme.value)

  document
    .querySelector('#theme-toggle')
    ?.setAttribute('aria-label', theme.value)
}

Un point important à noter à ce stade est 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 définir les deux pour les maintenir synchronisés, mais lors de la première exécution, elle ne pourra définir que la balise HTML. querySelector ne trouvera rien au début, et l'opérateur de chaînage facultatif garantit l'absence d'erreurs de syntaxe lorsqu'il n'est pas trouvé et que la fonction setAttribute tente d'être appelée.

Ensuite, cette fonction reflectPreference() est immédiatement appelée afin que l'attribut data-theme du document HTML soit défini :

reflectPreference()

Le bouton a toujours besoin de l'attribut. Attendez donc l'événement de chargement de la page, puis vous pourrez interroger, ajouter des écouteurs et définir des attributs sur :

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 d'activation/désactivation

Lorsque l'utilisateur clique sur le bouton, le thème doit être inversé, dans la mémoire JavaScript et dans le document. La valeur du thème actuel devra être inspectée et une décision devra ê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 particularité de ce sélecteur de thème est qu'il se synchronise avec la préférence système lorsqu'elle change. Si un utilisateur modifie ses préférences système alors qu'une page et ce composant sont visibles, le thème change pour correspondre aux nouvelles préférences de l'utilisateur, comme si celui-ci avait interagi avec le sélecteur de thème en même temps qu'avec le sélecteur système.

Pour ce faire, utilisez JavaScript et un écouteur d'événements matchMedia pour détecter les modifications apportées à une requête média :

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
La modification des préférences système de macOS change l'état du commutateur de thème.

Conclusion

Maintenant que vous savez comment j'ai fait, comment feriez-vous ? 🙂

Diversifions nos approches et découvrons toutes les façons de créer sur le Web. Créez une démo, tweetez-moi les liens et je l'ajouterai à la section des remix de la communauté ci-dessous !

Remix de la communauté