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 vais vous expliquer comment créer un composant de bascule entre les thèmes clair et sombre. Tester la fonctionnalité

Démonstration pour une meilleure visibilité

Si vous préférez la 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 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 considérations d'ingénierie Web doivent être prises 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.

Le diagramme présente un aperçu du chargement de la page JavaScript et des événements d'interaction avec le document pour montrer qu'il existe quatre chemins pour définir le thème.

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

L&#39;icône en forme de soleil affichée avec les rayons du soleil se fondant et une flèche rose pointant vers le cercle au centre.

Le graphique du soleil se compose d'un cercle et de lignes pour lesquelles le format 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, ce 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

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 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 formes circulaires créent un joli soleil avec des poutres.

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>
Illustration avec trois couches verticales pour expliquer le fonctionnement du masquage. La couche supérieure est un carré blanc avec un cercle noir. La couche du milieu correspond à l&#39;icône du soleil.
La couche inférieure est identifiée comme résultat. Elle affiche l&#39;icône du soleil avec une découpe à l&#39;emplacement du cercle noir de la couche supérieure.

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 en forme de soleil sera éclipsée par une forme de lune <circle> avec un masque SVG, simplement en déplaçant une forme circulaire à l'intérieur ou à 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 s'assurer que le résultat n'est pas très volumineux et 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 doit utiliser si le CSS ne se charge pas. Cela crée de jolis styles défensifs contre la turbulence 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 à 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 rapide. Supprimez la mise en surbrillance semi-transparente qu'iOS applique aux boutons. Enfin, laissez à l'état de mise au point un peu d'espace 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 nécessite également des styles. Le SVG doit s'adapter à la taille du bouton et, pour plus de douceur, 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 concernant 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

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 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, 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 aucun changement de couleur ni transition. 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 accélérations 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.

Passage du clair au sombre
Passage du sombre au clair

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;
      }
    }
  }
}
Transition claire vers sombre
Transition sombre vers claire

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 définir les deux pour qu'elles restent synchronisées, 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 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 page pour pouvoir interroger, ajouter des écouteurs et définir des attributs en toute sécurité 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 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()
}

Synchroniser 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()
  })
La modification des préférences système macOS modifie l'état du bouton de sélection du thème.

Conclusion

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

Diversifiez nos approches et découvrons toutes les manières de créer des applications 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é