Creazione di un componente per il cambio di tema

Una panoramica di base su come creare un componente di cambio tema adattabile e accessibile.

In questo post voglio condividere alcune idee su come creare un componente di attivazione/disattivazione del tema scuro e chiaro. Prova la demo.

Dimensioni del pulsante Demo aumentate per una maggiore visibilità

Se preferisci i video, ecco una versione di questo post su YouTube:

Panoramica

Un sito web potrebbe fornire impostazioni per controllare la combinazione di colori anziché basarsi interamente sulla preferenza di sistema. Ciò significa che gli utenti possono navigare in una modalità diversa da quella delle preferenze di sistema. Ad esempio, il sistema di un utente è impostato su un tema chiaro, ma l'utente preferisce che il sito web venga visualizzato in tema scuro.

Esistono diverse considerazioni di ingegneria web per la creazione di questa funzionalità. Ad esempio, il browser deve essere informato della preferenza il prima possibile per evitare sfarfallii di colore della pagina e il controllo deve prima sincronizzarsi con il sistema, quindi consentire le eccezioni memorizzate lato client.

Il diagramma mostra un'anteprima del caricamento della pagina e degli eventi di interazione con il documento JavaScript per mostrare in generale che esistono 4 percorsi per impostare il tema

Segni e linee

Per l'opzione di attivazione/disattivazione deve essere utilizzato un <button>, in quanto potrai usufruire di funzionalità ed eventi di interazione forniti dal browser, come gli eventi di clic e la possibilità di mettere in primo piano.

Il pulsante

Il pulsante ha bisogno di una classe da utilizzare in CSS e di un ID da utilizzare in JavaScript. Inoltre, poiché i contenuti del pulsante sono un'icona anziché del testo, aggiungi un attributo title per fornire informazioni sullo scopo del pulsante. Infine, aggiungi un [aria-label] per mantenere lo stato del pulsante dell'icona, in modo che gli screen reader possano condividere lo stato del tema con le persone con disabilità visive.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>

aria-label e aria-live educati

Per indicare agli screen reader che le modifiche a aria-label devono essere annunciate, aggiunge aria-live="polite" al pulsante.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>

Questa aggiunta di markup indica agli screen reader di comunicare all'utente in modo educato cosa è cambiato, anziché aria-live="assertive". Nel caso di questo pulsante, annuncerà "chiaro" o "scuro" a seconda di cosa è diventato aria-label.

L'icona Scalable Vector Graphics (SVG)

SVG consente di creare forme scalabili di alta qualità con un markup minimo. L'interazione con il pulsante può attivare nuovi stati visivi per i vettori, rendendo SVG ideale per le icone.

Il seguente markup SVG va inserito all'interno di <button>:

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  …
</svg>

aria-hidden è stato aggiunto all'elemento SVG in modo che gli screen reader sappiano di ignorarlo poiché è contrassegnato come elemento di presentazione. Questa tecnica è ideale per le decorazioni visive, come l'icona all'interno di un pulsante. Oltre all'attributo viewBox obbligatorio nell'elemento, aggiungi altezza e larghezza per motivi simili per cui le immagini devono avere dimensioni in linea.

Il sole

L&#39;icona del sole mostrata con i raggi del sole attenuati e una freccia fucsia brillante che punta al cerchio al centro.

L'immagine del sole è composta da un cerchio e da linee per le quali SVG ha forme utili. <circle> è centrato impostando le proprietà cx e cy su 12, che corrisponde alla metà della dimensione dell'area visibile (24), e poi viene assegnato un raggio (r) di 6 che imposta la dimensione.

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

Inoltre, la proprietà mask fa riferimento a un ID di un elemento SVG, che creerai in seguito, e infine viene assegnato un colore di riempimento che corrisponde al colore del testo della pagina con currentColor.

I raggi del sole

L&#39;icona del sole mostrata con il centro del sole attenuato e una freccia fucsia brillante rivolta verso i raggi di sole.

Successivamente, le linee dei raggi di sole vengono aggiunte appena sotto il cerchio, all'interno di un gruppo di elementi <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>

Questa volta, invece che il valore di fill sia currentColor, viene impostato il valore di stroke di ogni riga. Le linee e le forme circolari creano un bel sole con i raggi.

La Luna

Per creare l'illusione di una transizione senza interruzioni tra la luce (sole) e il buio (luna), la luna è un'estensione dell'icona del sole, utilizzando una maschera 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>
Grafica con tre livelli verticali per mostrare il funzionamento del mascheramento. Il livello superiore è un quadrato bianco con un cerchio nero. Il livello intermedio è l&#39;icona del sole.
Il livello inferiore è etichettato come risultato e mostra l&#39;icona del sole con un&#39;area ritagliata al posto del cerchio nero del livello superiore.

Le maschere con SVG sono potenti e consentono di rimuovere o includere parti di un'altra grafica con i colori bianco e nero. L'icona del sole verrà eclissata da una forma di luna <circle> con una maschera SVG, semplicemente spostando una forma circolare all'interno e all'esterno di un'area della maschera.

Che cosa succede se il CSS non viene caricato?

Screenshot di un semplice pulsante del browser con l&#39;icona del sole all&#39;interno.

Può essere utile testare l'SVG come se il CSS non fosse stato caricato per assicurarti che il risultato non sia molto grande o che non causi problemi di layout. Gli attributi altezza e larghezza incorporati nell'elemento SVG, oltre all'utilizzo di currentColor, forniscono regole di stile minime per il browser da utilizzare se il CSS non viene caricato. Questo consente di adottare stili difensivi efficaci contro le perturbazioni della rete.

Layout

Il componente di attivazione/disattivazione del tema ha una superficie ridotta, quindi non è necessario utilizzare la griglia o il flexbox per il layout. Vengono invece utilizzati il posizionamento SVG e le trasformazioni CSS.

Stili

Stili .theme-toggle

L'elemento <button> è il contenitore per le forme e gli stili delle icone. Questo contesto principale conterrà colori e dimensioni adattabili da trasmettere a SVG.

La prima operazione consiste nel creare un cerchio per il pulsante e rimuovere gli stili predefiniti:

.theme-toggle {
  --size: 2rem;
  
  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
}

Aggiungi alcuni stili di interazione. Aggiungi uno stile del cursore per gli utenti che usano il mouse. Aggiungitouch-action: manipulation per un'esperienza di tocco con reazione rapida. Rimuovi l'evidenziazione semitrasparente applicata da iOS ai pulsanti. Infine, lascia un po' di spazio tra il contorno dello stato di messa a fuoco e il bordo dell'elemento:

.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;
}

Anche l'SVG all'interno del pulsante ha bisogno di alcuni stili. L'SVG deve adattarsi alle dimensioni del pulsante e, per una maggiore morbidezza visiva, arrotondare le estremità della linea:

.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;
  }
}

Dimensionamento adattivo con la query media hover

Le dimensioni del pulsante dell'icona sono un po' piccole (2rem), il che va bene per gli utenti del mouse, ma può essere complicato per un cursore grossolano come un dito. Fai in modo che il pulsante soddisfi molte linee guida sulle dimensioni dei pulsanti utilizzando una query sui media con passaggio del mouse per specificare un aumento delle dimensioni.

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

Stili SVG per sole e luna

Il pulsante contiene gli aspetti interattivi del componente di attivazione/disattivazione del tema, mentre l'SVG al suo interno contiene gli aspetti visivi e animati. È qui che l'icona può essere personalizzata e resa più bella.

Tema chiaro

ALT_TEXT_HERE

Per fare in modo che le animazioni di ridimensionamento e rotazione vengano eseguite dal centro delle forme SVG, imposta il loro transform-origin: center center. I colori adattivi forniti dal pulsante vengono utilizzati qui dalle forme. La luna e il sole utilizzano i pulsanti fornitivar(--icon-fill) e var(--icon-fill-hover) per il riempimento, mentre i raggi di sole utilizzano le variabili per il tratto.

.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);
    }
  }
}

Tema scuro

ALT_TEXT_HERE

Gli stili della luna devono rimuovere i raggi di sole, aumentare di dimensioni il cerchio del sole e spostare la maschera del cerchio.

.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;
      }
    }
  }
}

Nota che il tema scuro non presenta transizioni o variazioni di colore. I colori sono di proprietà del componente pulsante principale, dove sono già adattabili in un contesto chiaro e scuro. Le informazioni sulla transizione devono essere alla base della query media relativa alle preferenze di movimento di un utente.

Animazione

A questo punto, il pulsante dovrebbe essere funzionale e con stato, ma senza transizioni. Le sezioni seguenti sono dedicate a definire come e cosa si deve eseguire durante le transizioni.

Condividere le query sui contenuti multimediali e importare le animazioni

Per semplificare l'applicazione di transizioni e animazioni in base alle preferenze di movimento del sistema operativo di un utente, il plug-in PostCSS Custom Media consente di utilizzare la sintassi della specifica CSS in fase di stesura per le variabili delle query supporti:

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

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

Per animazioni CSS uniche e facili da usare, importa la parte easings di Open Props:

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

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

Il sole

Le transizioni del sole saranno più giocose rispetto alla luna, ottenendo questo effetto con animazioni con variazioni graduali. I raggi di sole dovrebbero oscillare leggermente durante la rotazione e il centro del sole dovrebbe oscillare leggermente durante la scalata.

Gli stili predefiniti (tema chiaro) definiscono le transizioni, mentre gli stili del tema scuro definiscono le personalizzazioni per la transizione al tema chiaro:

​​.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;
      }
    }
  }
}

Nel pannello Animazione di Chrome DevTools, puoi trovare una sequenza temporale per le transizioni delle animazioni. Puoi controllare la durata dell'animazione totale, gli elementi e la temporizzazione della transizione.

Transizione da chiaro a scuro
Transizione da scuro a chiaro

La Luna

Le posizioni di luce e buio della luna sono già impostate, aggiungi gli stili di transizione all'interno della query multimediale --motionOK per dare vita alla luna rispettando le preferenze di movimento dell'utente.

La tempistica con ritardo e durata è fondamentale per rendere questa transizione pulita. Se il sole viene eclissato troppo presto, ad esempio, la transizione non sembra essere orchestrata o giocosa, ma caotica.

​​.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;
      }
    }
  }
}
Transizione da chiaro a scuro
Transizione da scuro a chiaro

Preferisce movimento ridotto

Nella maggior parte delle sfide della GUI, cerco di mantenere alcune animazioni, come le dissolvenze incrociate di opacità, per gli utenti che preferiscono ridurre il movimento. Tuttavia, questo componente è stato migliorato con le modifiche istantanee dello stato.

JavaScript

Questo componente richiede molto lavoro per JavaScript, dalla gestione delle informazioni ARIA per gli screen reader all'ottenimento e all'impostazione dei valori dallo spazio di archiviazione locale.

L'esperienza di caricamento della pagina

È importante che non si verifichino lampi di colore al caricamento della pagina. Se un utente con una combinazione di colori scura indica che preferisce la modalità chiara per questo componente, la pagina viene ricaricata e inizialmente è scura, quindi diventa chiara. Per evitare questo problema, è stato necessario eseguire una piccola quantità di codice JavaScript di blocco con l'obiettivo di impostare l'attributo HTML data-theme il prima possibile.

<script src="./theme-toggle.js"></script>

Per farlo, viene caricato prima un tag <script> normale nel documento <head>, prima di qualsiasi markup CSS o <body>. Quando il browser rileva uno script non contrassegnato come questo, esegue il codice prima del resto del codice HTML. Se utilizzi questo momento di blocco con parsimonia, è possibile impostare l'attributo HTML prima che il CSS principale dipinga la pagina, impedendo così un lampo o i colori.

Il codice JavaScript controlla prima la preferenza dell'utente nello spazio di archiviazione locale e, se non viene trovato nulla, passa a controllare la preferenza di sistema:

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'
}

Viene analizzata una funzione per impostare la preferenza dell'utente nello spazio di archiviazione locale:

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

Seguita da una funzione per modificare il documento con le preferenze.

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

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

A questo punto è importante notare lo stato di analisi del documento HTML. Il browser non conosce ancora il pulsante "#theme-toggle", poiché il tag <head> non è stato analizzato completamente. Tuttavia, il browser ha un document.firstElementChild, ovvero il tag <html>. La funzione tenta di impostarli entrambi per mantenerli sincronizzati, ma alla prima esecuzione potrà impostare solo il tag HTML. querySelector inizialmente non troverà nulla e l'operatore di associazione facoltativa garantisce che non si verifichino errori di sintassi quando non viene trovato e si tenta di richiamare la funzione setAttribute.

Successivamente, la funzione reflectPreference() viene chiamata immediatamente in modo che il documento HTML abbia l'attributo data-theme impostato:

reflectPreference()

Il pulsante ha ancora bisogno dell'attributo, quindi attendi l'evento di caricamento della pagina, dopodiché potrai eseguire query, aggiungere listener e impostare attributi su:

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'esperienza di attivazione/disattivazione

Quando si fa clic sul pulsante, il tema deve essere scambiato nella memoria JavaScript e nel documento. Il valore del tema corrente dovrà essere esaminato e dovrà essere presa una decisione sul suo nuovo stato. Una volta impostato il nuovo stato, salvalo e aggiorna il documento:

const onClick = () => {
  theme.value = theme.value === 'light'
    ? 'dark'
    : 'light'

  setPreference()
}

Sincronizzazione con il sistema

Una caratteristica unica di questo passaggio di tema è la sincronizzazione con la preferenza di sistema man mano che cambia. Se un utente modifica la preferenza di sistema mentre una pagina e questo componente sono visibili, il pulsante di attivazione/disattivazione del tema cambierà in base alla nuova preferenza dell'utente, come se l'utente avesse interagito con il pulsante di attivazione/disattivazione del tema contemporaneamente al pulsante di attivazione/disattivazione del sistema.

Per farlo, utilizza JavaScript e un matchMedia evento in ascolto delle modifiche a una query sui contenuti multimediali:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
La modifica della preferenza di sistema di macOS cambia lo stato dell'opzione di attivazione/disattivazione del tema

Conclusione

Ora che sai come ho fatto, come faresti? 🙂

Diversifichiamo i nostri approcci e impariamo tutti i modi per creare sul web. Crea una demo, twittami i link e io la aggiungerò alla sezione dei remix della community di seguito.

Remix della community