Creazione di un componente per il cambio di tema

Una panoramica generale su come creare un componente per il cambio di tema adattivo e accessibile.

In questo post voglio condividere una riflessione su come creare un componente per cambiare tema chiaro e scuro. Prova la demo.

Dimensioni del pulsante Demo aumentate per facilitare la visibilità

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

Panoramica

Un sito web potrebbe fornire impostazioni per il controllo della 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 ha un tema chiaro, ma l'utente preferisce che il sito web venga visualizzato con il tema scuro.

Quando si crea questa funzionalità, occorre tenere presenti diversi aspetti di web engineering. Ad esempio, è necessario informare il browser della preferenza il prima possibile per evitare che i colori delle pagine lampeggino e il controllo deve prima sincronizzarsi con il sistema, quindi consentire le eccezioni archiviate lato client.

Il diagramma mostra un'anteprima degli eventi di caricamento pagina di JavaScript e di interazione dei documenti per mostrare, nel complesso, che sono previsti 4 percorsi per impostare il tema

Markup

Utilizza <button> per l'attivazione/disattivazione, in modo da trarre vantaggio dalle funzionalità e dagli eventi di interazione forniti dal browser, come gli eventi di clic e l'impostazione dello stato attivo.

Il pulsante

Il pulsante richiede una classe per l'utilizzo da parte del CSS e un ID per l'utilizzo da JavaScript. Inoltre, poiché i contenuti del pulsante sono un'icona e non un testo, aggiungi un attributo title per fornire informazioni sullo scopo del pulsante. Infine, aggiungi [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à visiva.

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

aria-label e aria-live in modo "polite"

Per indicare agli screen reader che deve essere annunciata la modifica in aria-label, aggiungi 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 al markup segnala agli screen reader di comunicare all'utente in modo educativo, invece di aria-live="assertive", che cosa è cambiato. Nel caso di questo pulsante, il valore annuncia "chiaro" o "scuro", a seconda di come è diventato aria-label.

L'icona della grafica vettoriale scalabile (SVG)

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

Il seguente markup SVG va 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 perché è contrassegnato come di presentazione. È un'ottima soluzione 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 dovrebbero avere dimensioni in linea.

Il sole

L&#39;icona del sole mostrata con i raggi di sole sbiaditi e una freccia rosa acceso che punta al cerchio al centro.

La grafica del sole è costituita da un cerchio e da linee per le quali il formato SVG ha le forme. La colonna <circle> viene centrata impostando le proprietà cx e cy su 12, ovvero la metà della dimensione dell'area visibile (24), e impostando un raggio (r) pari a 6, che determina le dimensioni.

<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à della maschera punta a un ID dell'elemento SVG, che creerai in seguito e ti verrà assegnato un colore di riempimento corrispondente al colore del testo della pagina currentColor.

I raggi del sole

L&#39;icona del sole mostrata con il centro del sole sbiadito e una freccia rosa acceso che punta verso i raggi del sole.

Successivamente, le linee del raggio 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 di impostare il valore fill su currentColor, viene impostato il tratto di ogni riga. Le linee e le forme del cerchio creano un bel sole con raggi.

La Luna

Per creare l'illusione di una transizione fluida tra luce (sole) e scuro (luna), la luna è un aumento dell'icona del sole mediante 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>
Immagine con tre livelli verticali per mostrare il funzionamento del mascheramento. Il livello superiore
è un quadrato bianco con un cerchio nero. Il livello centrale è l&#39;icona del sole.
Il livello inferiore è etichettato come risultato e mostra l&#39;icona del sole con un ritaglio nel punto del cerchio nero del livello superiore.

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

Cosa succede se il CSS non viene caricato?

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

Può essere utile testare il file SVG come se il CSS non si caricasse per verificare che il risultato non sia molto grande o che non causi problemi di layout. Gli attributi di altezza e larghezza incorporati nel file SVG e l'uso di currentColor offrono regole di stile minime per il browser da utilizzare nel caso in cui il CSS non venga caricato. Questo crea stili difensivi efficaci contro le turbolenze della rete.

Layout

Il componente Cambia tema ha una superficie ridotta, pertanto non sono necessarie griglia o flexbox per il layout. Vengono invece utilizzati il posizionamento SVG e le trasformazioni CSS.

Stili

.theme-toggle stili

L'elemento <button> è il contenitore delle forme e degli stili delle icone. Questo contesto principale manterrà i colori e le dimensioni adattivi da trasmettere al formato SVG.

La prima attività consiste nel trasformare il pulsante in un cerchio e rimuovere gli stili predefiniti dei pulsanti:

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

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

Poi, aggiungi alcuni stili di interazione. Aggiungi uno stile di cursore per gli utenti del mouse. Aggiungi touch-action: manipulation per un'esperienza al tocco a reazione rapida. Rimuovi l'evidenziazione semitrasparente che iOS si applica ai pulsanti. Infine, assegna allo stato attivo un po' di respiro dal 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 il file SVG all'interno del pulsante richiede alcuni stili. Il file SVG deve adattarsi alle dimensioni del pulsante e, per una maggiore morbidezza visiva, arrotonda 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;
  }
}

Ridimensionamento adattivo con la query supporti hover

Le dimensioni del pulsante dell'icona sono leggermente piccole (2rem) il che è accettabile per gli utenti del mouse, ma può essere difficile utilizzare un puntatore di grandi dimensioni come un dito. Fai in modo che il pulsante rispetti molte linee guida per le dimensioni del tocco utilizzando una query multimediale con passaggio del mouse per specificare un aumento delle dimensioni.

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

Stili SVG di sole e luna

Il pulsante contiene gli aspetti interattivi del componente per il cambio di tema, mentre il formato SVG al suo interno conterrà gli aspetti visivi e animati. È qui che l'icona può essere resa bella e realizzata.

Tema chiaro

ALT_TEXT_HERE

Per scalare e ruotare le animazioni dal centro delle forme SVG, imposta il relativo transform-origin: center center. I colori adattivi forniti dal pulsante vengono utilizzati qui dalle forme. La luna e il sole utilizzano il pulsante fornito var(--icon-fill) e var(--icon-fill-hover) per il riempimento, mentre i raggi del 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 lunare devono rimuovere i raggi di sole, ingrandire 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: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

Tieni presente che il tema scuro non ha modifiche o transizioni di colore. Il componente del pulsante principale è proprietario dei colori, che sono già adattivi in un contesto chiaro e scuro. Le informazioni sulla transizione devono essere dietro la query multimediale di preferenza di movimento dell'utente.

Animazione

Il pulsante deve essere funzionale e stateful, ma senza transizioni a questo punto. Le sezioni seguenti descrivono tutte le transizioni come e cosa.

Condivisione di query multimediali e importazione degli easing

Per semplificare l'inserimento di transizioni e animazioni dietro le preferenze di movimento del sistema operativo di un utente, il plug-in PostCSS Custom Media consente di utilizzare la sintassi di specifica CSS bozza per le variabili di query multimediali:

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

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

Per easing CSS unici e facili da usare, importa la parte relativa all'easing 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ù gioiose della luna, ottenendo questo effetto grazie a un rilassamento rimbalzante. I raggi del sole dovrebbero rimbalzare leggermente durante la rotazione e il centro del sole dovrebbe rimbalzare leggermente durante la rotazione.

Gli stili predefiniti (tema chiaro) definiscono le transizioni e gli stili del tema scuro definiscono le personalizzazioni per la transizione alla luce:

​​.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 riquadro Animazione di Chrome DevTools puoi trovare una sequenza temporale per le transizioni delle animazioni. È possibile controllare la durata dell'animazione totale, gli elementi e la tempistica di easing.

Transizione da chiaro a scuro
Transizione da scuro a chiaro

La Luna

Le posizioni buia e chiara della luna sono già impostate. Aggiungi stili di transizione all'interno della query multimediale --motionOK per renderla più reale rispettando le preferenze di movimento dell'utente.

Il ritardo e la durata sono fondamentali per rendere chiara questa transizione. Se il sole viene eclissato troppo presto, ad esempio, la transizione non sembra orchestrata o giocosa, ma risulta caotica.

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

Preferisce il movimento ridotto

Nella maggior parte delle GUI Challenge, cerco di mantenere un'animazione, come le dissolvenze incrociate di opacità, per gli utenti che preferiscono un movimento ridotto. Tuttavia, questo componente ha funzionato meglio con modifiche di stato istantanee.

JavaScript

È molto lavoro per JavaScript in questo componente, dalla gestione delle informazioni ARIA per gli screen reader, al recupero e all'impostazione dei valori dallo spazio di archiviazione locale.

L'esperienza di caricamento delle pagine

Era importante che il colore non lampeggiasse durante il caricamento pagina. Se un utente con una combinazione di colori scuri indica di preferire la luce con questo componente e poi ricarica la pagina, che all'inizio diventa scura, poi lampeggia in chiara. Evitare questo problema comportava l'esecuzione di 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>

A questo scopo, viene caricato un tag <script> semplice nel documento <head>, prima di qualsiasi markup CSS o <body>. Quando il browser rileva uno script non contrassegnato come questo, esegue il codice e lo esegue prima del resto del codice HTML. Se utilizzi questo momento di blocco con parsimonia, puoi impostare l'attributo HTML prima che il CSS principale dipingi la pagina, evitando così flash o colori.

JavaScript verifica innanzitutto la preferenza dell'utente nello spazio di archiviazione locale, quindi fallback per controllare la preferenza di sistema se non viene trovato nulla nello spazio di archiviazione:

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 quindi analizzata una funzione per impostare la preferenza dell'utente nello spazio di archiviazione locale:

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

Seguito 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)
}

Una cosa importante da notare a questo punto è lo stato dell'analisi dei documenti HTML. Il browser non è ancora a conoscenza del pulsante "#theme-toggle", poiché il tag <head> non è stato analizzato completamente. Tuttavia, il browser dispone di un tag document.firstElementChild, noto anche come tag <html>. La funzione tenta di impostarli entrambi per mantenerli sincronizzati, ma alla prima esecuzione sarà possibile impostare solo il tag HTML. All'inizio querySelector non troverà nulla e l'operatore di concatenamento facoltativo assicura che non ci siano errori di sintassi se 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 impostato il suo attributo data-theme:

reflectPreference()

Il pulsante necessita ancora 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 attuale dovrà essere controllato e dovrà essere presa una decisione in merito al nuovo stato. Una volta impostato il nuovo stato, salva e aggiorna il documento:

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

  setPreference()
}

Sincronizzazione con il sistema

Unicità di questo cambio di tema è la sincronizzazione con la preferenza di sistema man mano che cambia. Se un utente cambia la preferenza di sistema mentre una pagina e questo componente sono visibili, l'interruttore del tema cambia in base alla preferenza del nuovo utente, come se l'utente avesse interagito con l'opzione del tema nello stesso momento in cui il passaggio di sistema.

Puoi ottenere questo risultato con JavaScript e un evento matchMedia che rileva le modifiche a una query multimediale:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
La modifica della preferenza di sistema di macOS comporta la modifica dello stato del cambio di tema

Conclusione

Ora che sai come ci sono riuscito, come faresti? 🙂

Diversifica i nostri approcci e scopriamo tutti i modi per creare sul web. Crea una demo, inviami un tweet con i link e lo aggiungerò alla sezione Remix della community di seguito.

Remix della community