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