Créer un composant de changement de thème

Présentation des principes 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 transition entre le thème sombre et le thème clair. Tester la fonctionnalité

Démo pour une meilleure visibilité

Si vous préférez la vidéo, voici une version YouTube de ce post:

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 leurs préférences système. Par exemple, le système d'un utilisateur est dans un thème clair, mais l'utilisateur préfère que le site Web s'affiche dans le thème sombre.

Plusieurs considérations d'ingénierie Web sont à prendre en compte lors de la création de cette fonctionnalité. Par exemple, le navigateur doit être informé de cette préférence dès que possible pour éviter que la couleur de la page ne clignote. La commande doit d'abord être synchronisée avec le système, puis autoriser les exceptions stockées côté client.

Ce schéma présente un aperçu du chargement de page JavaScript et des événements d'interaction avec les documents, montrant qu'il existe quatre chemins pour définir le thème.

Markup

Vous devez utiliser un <button> pour le bouton d'activation/de désactivation, car vous bénéficierez ainsi des fonctionnalités et événements d'interaction fournis par le navigateur, comme les événements de clic et la sélection.

Le bouton

Le bouton nécessite une classe à utiliser à partir de CSS et un ID à utiliser à partir de JavaScript. De plus, étant donné que le contenu du bouton est une icône et non du texte, ajoutez un attribut title pour fournir des informations sur la fonction 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>

Message poli de aria-label et aria-live

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 qu'ils doivent poliment, au lieu de aria-live="assertive", indiquer à l'utilisateur ce qui a changé. Dans le cas de ce bouton, il annoncera "light" (clair) ou "dark" (foncé) en fonction de ce que aria-label est devenu.

Icône du graphique vectoriel évolutif (SVG)

Le SVG permet de créer des formes évolutives de haute qualité avec un balisage minimal. Interagir avec le bouton peut déclencher de nouveaux états visuels pour les vecteurs, ce qui rend le format SVG idéal pour les icônes.

Le balisage SVG suivant est inclus 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 afin que les lecteurs d'écran sachent qu'il doit l'ignorer lorsqu'il est marqué comme présentation. C'est très pratique pour les décorations visuelles, comme l'icône à l'intérieur d'un bouton. En plus de l'attribut viewBox obligatoire de l'élément, ajoutez la hauteur et la largeur pour des raisons similaires pour lesquelles les images doivent avoir des tailles intégrées.

Le soleil

Icône du soleil affichée avec les rayons de soleil estompés et une flèche rose vif pointant vers le cercle au centre

L'image représentant le soleil se compose d'un cercle et de lignes, pour lesquelles le format SVG peut utiliser des formes. <circle> est centré en définissant les propriétés cx et cy sur 12, ce qui correspond à la moitié de la taille de la fenêtre d'affichage (24), puis en donnant 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>

En outre, la propriété de masque pointe vers l'ID d'un élément SVG, que vous allez créer ensuite. Enfin, vous lui attribuez une couleur de remplissage correspondant à la couleur du texte de la page par currentColor.

Rayons du soleil

Icône du soleil affichée avec son centre s&#39;estompé et une flèche rose vif pointant vers les rayons du soleil.

Ensuite, les lignes de rayons de soleil sont ajoutées juste en dessous du cercle, à l'intérieur d'un groupe d'éléments 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 fill soit currentColor, le trait de chaque ligne est défini. Les lignes et les formes circulaires forment un beau 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, en utilisant 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 montrer le fonctionnement du masquage. La couche supérieure est un carré blanc avec un cercle noir. La couche du milieu est l&#39;icône du soleil.
La couche inférieure est libellée comme résultat et affiche l&#39;icône du soleil avec une découpe là où se trouve le cercle noir de la couche supérieure.

Les masques au format SVG sont puissants, car ils permettent aux couleurs blanc et noir de supprimer ou d'inclure des parties d'une autre image. 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 circulaire à 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 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 très volumineux ou ne provoque pas de problèmes de mise en page. Les attributs de hauteur et de largeur intégrés sur le SVG et l'utilisation de currentColor donnent des règles de style minimales que le navigateur doit utiliser si CSS ne se charge pas. Cela permet d'obtenir des styles défensifs intéressants contre les turbulences du réseau.

Mise en page

Le composant de changement de thème a une faible surface. Vous n'avez donc pas besoin de grille ni de Flexbox pour la mise en page. À la place, le positionnement et les transformations CSS sont utilisés.

Styles

.theme-toggle styles

L'élément <button> contient les formes et les styles des icônes. Ce contexte parent contiendra les couleurs et les tailles adaptatives à transmettre au format 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%;
}

Ensuite, ajoutez quelques styles d'interaction. Ajoutez un style de curseur pour les utilisateurs de souris. Ajout de touch-action: manipulation pour une expérience tactile à réaction rapide. Suppression de la mise en surbrillance semi-transparente des boutons appliquée par iOS. Enfin, laissez à l'état de mise au point définir le contour de l'espace à partir du 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 quelques styles. Le SVG doit s'adapter à la taille du bouton et, pour plus de douceur visuelle, arrondissez les extrémités de la ligne:

.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 de l'icône est un peu petite dans 2rem, ce qui convient aux utilisateurs de souris, mais peut s'avérer difficile pour un pointeur grossier comme un doigt. Faites en sorte que le bouton respecte de nombreuses consignes relatives à la taille des éléments tactiles en utilisant une requête de média par survol pour spécifier une augmentation de taille.

.theme-toggle {
  --size: 2rem;
  …
  
  @media (hover: none) {
    --size: 48px;
  }
}

Styles SVG Soleil et Lune

Le bouton contient les aspects interactifs du composant switch de thème, tandis que SVG contient les aspects visuels et animés. C'est là que l'icône peut être faite belle et vivante.

Thème clair

ALT_TEXT_HERE

Pour que les animations de mise à l'échelle et de rotation s'effectuent à partir du centre des formes SVG, définissez leurs 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) fournis pour leur remplissage, tandis que les rayons de soleil utilisent les variables du 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 Lune doivent supprimer les rayons de soleil, augmenter la taille du cercle du soleil et déplacer le masque de cercle.

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

Notez que le thème sombre ne comporte pas de changement de couleur ni de transition. Le composant du bouton parent possède les couleurs, où elles s'adaptent déjà dans un contexte sombre et clair. Les informations de transition doivent figurer derrière la requête média de préférences de mouvement de l'utilisateur.

Animation

Le bouton doit être fonctionnel et avec état, mais sans transitions à ce stade. Les sections suivantes expliquent comment définir les transitions de type comment et quoi.

Partager des requêtes média et importer des lissages de vitesse

Pour faciliter l'insertion des transitions et des animations derrière les préférences de mouvement du système d'exploitation de l'utilisateur, le plug-in PostCSS Custom Media permet d'utiliser la syntaxe de la spécification CSS rédigée 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 créer des lissages de vitesse CSS uniques et faciles à utiliser, importez la partie Lissage de vitesse d'Open Props:

@import "https://unpkg.com/open-props/easings.min.css";

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

Le soleil

Les transitions du soleil seront plus ludiques que la Lune, ce qui permet d'obtenir cet effet à l'aide de lissage de vitesse des rebonds. Les rayons de soleil doivent rebondir légèrement lorsqu'ils pivotent, et le centre du soleil doit rebondir légèrement à mesure qu'il s'agrandit.

Les styles par défaut (thème clair) définissent les transitions, tandis que les styles du thème sombre définissent les personnalisations de la transition vers le mode 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 timeline pour les transitions d'animation. Vous pouvez inspecter la durée totale de l'animation, les éléments et la durée du lissage de vitesse.

Transition claire à sombre
Transition sombre vers clair

La Lune

Les positions claire et sombre sont déjà définies. Ajoutez des styles de transition dans la requête média --motionOK pour lui donner vie tout en respectant les préférences de mouvement de l'utilisateur.

Le calendrier avec le retard et la durée sont essentiels pour rendre cette transition claire. Si le soleil est éclipsé trop tôt, par exemple, la transition ne semble pas orchestrée ni ludique, elle semble chaotique.

​​.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
Transition claire à sombre
Transition sombre à claire

Préfère les mouvements réduits

Dans la plupart des défis de l'IUG, j'essaie de conserver une animation, comme les fondus croisés d'opacité, pour les utilisateurs qui préfèrent réduire les mouvements. Toutefois, ce composant était mieux avec des changements d'état instantanés.

JavaScript

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

L'expérience de chargement de la page

Il était important qu'aucun clignotement des couleurs 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 a actualisé la page, la page est d'abord sombre, puis elle clignote. Pour éviter cela, il fallait exécuter 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 d'abord chargée, avant tout balisage CSS ou <body>. Lorsque le navigateur rencontre un tel script non marqué comme celui-ci, il exécute le code et l'exécute avant le reste du code HTML. En utilisant ce blocage avec parcimonie, il est possible de définir l'attribut HTML avant que le CSS principal ne peigne la page, ce qui permet d'éviter un flash ou des couleurs.

Le code JavaScript vérifie d'abord les préférences de l'utilisateur pour le stockage local, puis vérifie les préférences système si rien n'est détecté dans l'espace de 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 analysée ensuite:

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

Ensuite, une fonction permet 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)
}

Il est important de noter à ce stade 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é complètement analysée. Toutefois, le navigateur dispose d'une balise document.firstElementChild, également appelée balise <html>. La fonction tente de définir les deux afin qu'elles restent synchronisées, mais à la première exécution, elle ne pourra définir que la balise HTML. Au début, querySelector ne trouve rien et l'opérateur de chaînage facultatif garantit qu'aucune erreur de syntaxe n'est détectée lorsqu'il est introuvable et que la fonction setAttribute est tentée 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 que l'événement de chargement de page se déclenche. Vous pourrez alors interroger, ajouter des écouteurs et définir les 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)
}

L'expérience d'activation et de désactivation

Lorsque l'utilisateur clique sur le bouton, le thème doit être échangé, dans la mémoire JavaScript et dans le document. Vous devez examiner la valeur du thème actuel et prendre une décision 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

Ce changement de thème est propre à la synchronisation avec les préférences système au fur et à mesure qu'elles changent. Si un utilisateur modifie ses préférences système alors qu'une page et ce composant sont visibles, le changement de thème change pour correspondre à la nouvelle préférence utilisateur, comme si l'utilisateur avait interagi avec le changement de thème en même temps que le changement du système.

Pour ce faire, utilisez JavaScript et un événement matchMedia qui écoute 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 modifie l'état du changement de thème

Conclusion

Maintenant que vous savez comment je l'ai fait, comment le feriez-vous‽ 😃 ?

Diversissons nos approches et apprenons toutes les façons de créer sur le Web. Créez une démo, cliquez sur les liens tweet me, et je l'ajouterai à la section "Remix" de la communauté ci-dessous.

Remix de la communauté