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 facile 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 durante 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 degli eventi di interazione e delle funzionalità fornite dal browser, come gli eventi di clic e la possibilità di mettere in primo piano.

Il pulsante

Il pulsante richiede una classe per l'utilizzo da CSS e un ID per l'utilizzo da 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 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 educati

Per indicare agli screen reader che dovrebbero essere annunciate le modifiche a 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 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. È 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 devono avere dimensioni in linea.

Il sole

L&#39;icona del sole mostrata con i raggi del sole sbiaditi e una freccia rosa che punta verso il 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 dell'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. Lo strato 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 in cui si trova il 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, insieme all'uso 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 Cambio tema ha una superficie ridotta, quindi non hai bisogno di griglia o 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 utilizzano il mouse. Aggiungitouch-action: manipulation per un'esperienza di tocco con reazione rapida. Rimuovi l'evidenziazione semitrasparente applicata da iOS ai pulsanti. Infine, applica 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 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 un problema 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 Cambia tema, mentre il formato SVG all'interno conterrà gli aspetti visivi e animati. È qui che l'icona può essere realizzata e valorizzata.

Tema chiaro

ALT_TEXT_HERE

Per ridimensionare e ruotare le animazioni dal centro delle forme SVG, imposta transform-origin: center center. I colori adattivi forniti dal pulsante sono usati 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 cambiamenti 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 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 trasferisce.

Condivisione delle query sui contenuti multimediali e importazione delle 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 con tema scuro definiscono le personalizzazioni per il passaggio 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. È possibile esaminare la durata dell'animazione totale, gli elementi e i tempi di easing.

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 il ritardo e la durata sono fondamentali per rendere pulita questa transizione. 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 ridurre il movimento

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 si è rivelato migliore con modifiche istantanee di 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.

Esperienza di caricamento pagina

È importante che non si verifichino lampi di colore al caricamento della pagina. Se un utente con una combinazione di colori scuri indica che preferisce luce con questo componente e poi ha ricaricato la pagina, inizialmente la pagina sarebbe scura, quindi il flash di luce lampeggia. Per evitare questo problema, occorre eseguire una piccola quantità di blocco JavaScript 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, noto anche come tag <html>. La funzione tenta di impostare entrambi per mantenerli sincronizzati, ma alla prima esecuzione sarà in grado di 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 gli 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. Dovrai ispezionare il valore del tema corrente e prendere una decisione sul 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.

Puoi farlo con JavaScript e un matchMedia evento in ascolto per le 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? 🙂

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

Remix della community