Una panoramica di base su come creare un componente di cambio tema adattivo e accessibile.
In questo post voglio condividere il mio pensiero su un modo per creare un componente di commutazione del tema scuro e chiaro. Prova la demo.
Se preferisci i video, ecco una versione di questo post su YouTube:
Panoramica
Un sito web può fornire impostazioni per controllare la combinazione di colori anziché basarsi interamente sulla preferenza di sistema. Ciò significa che gli utenti potrebbero navigare in una modalità diversa dalle preferenze di sistema. Ad esempio, il sistema di un utente è in tema chiaro, ma l'utente preferisce che il sito web venga visualizzato nel tema scuro.
Esistono diverse considerazioni di ingegneria web da tenere presenti durante la creazione di questa funzionalità. Ad esempio, il browser deve essere informato della preferenza il prima possibile per evitare flash di colore della pagina e il controllo deve prima sincronizzarsi con il sistema, quindi consentire le eccezioni archiviate lato client.
Segni e linee
Per l'attivazione/disattivazione deve essere utilizzato un <button>, in quanto in questo modo si usufruisce di eventi e funzionalità di interazione forniti dal browser, come eventi di clic e possibilità di mettere a fuoco.
Il pulsante
Il pulsante deve avere una classe da utilizzare con CSS e un ID da utilizzare con JavaScript.
Inoltre, poiché il contenuto del pulsante è un'icona anziché un 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 devono annunciare 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, anziché
aria-live="assertive",
cosa è cambiato. Nel caso di questo pulsante, verrà annunciato "chiaro"
o "scuro" a seconda di come è diventato aria-label.
L'icona grafica vettoriale scalabile (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 perché è
contrassegnato come elemento di presentazione. È ideale per decorazioni visive, come l'icona
all'interno di un pulsante. Oltre all'attributo viewBox obbligatorio nell'elemento,
aggiungi altezza e larghezza per motivi simili a quelli per cui le immagini devono avere dimensioni
in linea.
Il sole
![]()
Il grafico del sole è costituito da un cerchio e da linee per cui SVG ha comodamente forme. Il <circle> è centrato impostando le proprietà cx e cy su 12,
che è la metà delle dimensioni del viewport (24), e poi viene assegnato un raggio (r) di 6
che imposta 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à mask punta all'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
![]()
Successivamente, le linee del raggio di sole vengono aggiunte appena sotto il cerchio, all'interno di un elemento
<g>
gruppo.
<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, anziché il valore di
fill pari a
currentColor, viene impostato
stroke di ogni
linea. Le linee e le forme circolari creano un bel sole con i raggi.
La luna
Per creare l'illusione di una transizione fluida tra luce (sole) e buio (luna), la luna è un'integrazione dell'icona del sole, che utilizza 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 e consentono ai colori bianco e nero di rimuovere o includere
parti di un'altra grafica. L'icona del sole verrà eclissata da una luna
<circle>
con una maschera SVG, semplicemente spostando una forma circolare dentro e fuori da un'area
mascherata.
Che cosa succede se il CSS non viene caricato?
Può essere utile testare il file SVG come se il CSS non fosse stato caricato per assicurarsi che il risultato non sia
troppo grande o non causi problemi di layout. Gli attributi di altezza e larghezza in linea sull'SVG e l'utilizzo di currentColor forniscono regole di stile minime che il browser può utilizzare se il CSS non viene caricato. In questo modo si ottengono stili difensivi efficaci contro le turbolenze di rete.
Layout
Il componente di cambio tema ha una superficie ridotta, quindi non hai bisogno di griglie o flexbox per il layout. Vengono invece utilizzate le trasformazioni CSS e il posizionamento SVG.
Stili
.theme-toggle stili
L'elemento <button> è il contenitore delle forme e degli stili delle icone. Questo
contesto principale conterrà colori e dimensioni adattivi da passare all'SVG.
La prima attività consiste nel trasformare il pulsante in un cerchio e rimuovere gli stili predefiniti del pulsante:
.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 del cursore per gli utenti del mouse. Aggiungi
touch-action: manipulation per un'esperienza
tattile a reazione rapida.
Rimuovi l'evidenziazione semitrasparente che iOS applica ai pulsanti. Infine, lascia un po' di spazio tra il bordo dell'elemento e il contorno dello stato attivo:
.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 richiede alcuni stili. Il file SVG deve adattarsi alle dimensioni del pulsante e, per una morbidezza visiva, arrotondare le estremità delle linee:
.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 supporti hover
La dimensione del pulsante dell'icona è un po' piccola, 2rem, il che va bene per gli utenti del mouse, ma
può essere difficile per un puntatore grossolano come un dito. Fai in modo che il pulsante soddisfi molte
linee guida sulle dimensioni
del tocco
utilizzando una query
hover media per specificare
un aumento delle dimensioni.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Stili SVG per il sole e la luna
Il pulsante contiene gli aspetti interattivi del componente di cambio tema, mentre l'SVG all'interno contiene gli aspetti visivi e animati. È qui che l'icona può essere resa bella e prendere vita.
Tema chiaro
Affinché le animazioni di ridimensionamento e rotazione avvengano 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 solari 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 della luna devono rimuovere i raggi solari, aumentare la scala del 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 modifiche o transizioni di colore. Il componente del pulsante principale possiede i colori, che sono già adattivi in un contesto scuro e chiaro. Le informazioni sulla transizione devono trovarsi dietro una media query delle preferenze di movimento dell'utente.
Animazione
Il pulsante deve essere funzionale e con stato, ma senza transizioni a questo punto. Le sezioni seguenti riguardano la definizione di come e cosa sono le transizioni.
Condivisione delle query sui contenuti multimediali e importazione delle curve di accelerazione
Per semplificare l'inserimento di transizioni e animazioni in base alle preferenze di movimento del sistema operativo di un utente, il plug-in PostCSS Custom Media consente l'utilizzo della sintassi della bozza della specifica CSS per le variabili delle media query:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
Per smorzamenti CSS unici 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 a quelle della luna, ottenendo questo effetto con accelerazioni elastiche. I raggi del sole devono rimbalzare leggermente mentre ruotano e il centro del sole deve rimbalzare leggermente mentre si ridimensiona.
Gli stili predefiniti (tema chiaro) definiscono le transizioni e 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 riquadro Animazione di Chrome DevTools, puoi trovare una cronologia delle transizioni di animazione. È possibile controllare la durata dell'animazione totale, degli elementi e della temporizzazione dell'accelerazione.
La luna
Le posizioni della luna chiara e scura sono già impostate. Aggiungi gli stili di transizione all'interno
della media query --motionOK per animarla 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 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;
}
}
}
}
Preferenze di movimento ridotto
Nella maggior parte delle sfide della GUI cerco di mantenere qualche animazione, come le dissolvenze incrociate dell'opacità, per gli utenti che preferiscono il movimento ridotto. Questo componente, tuttavia, è più efficace con modifiche di stato immediate.
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 storage locale.
L'esperienza di caricamento della pagina
Era importante che non si verificasse alcun lampeggio di colore al caricamento della pagina. Se un utente con una
combinazione di colori scuri indica di preferire la modalità chiara con questo componente, quindi
ricarica la pagina, all'inizio la pagina sarà scura, poi diventerà chiara.
Per evitare questo problema, è stato necessario eseguire una piccola quantità di JavaScript di blocco con l'obiettivo di impostare l'attributo HTML data-theme il prima possibile.
<script src="./theme-toggle.js"></script>
Per ottenere questo risultato, nel documento <head> viene caricato prima un tag <script> semplice, prima di qualsiasi markup CSS o <body>. Quando il browser rileva uno script non contrassegnato come questo, esegue il codice prima del resto dell'HTML. Utilizzando questo momento di blocco con parsimonia, è possibile impostare l'attributo HTML prima che il CSS principale disegni la pagina, evitando così un flash o colori.
JavaScript verifica innanzitutto la preferenza dell'utente nell'archivio locale e ricorre alla preferenza di sistema se non viene trovato nulla nell'archivio:
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'
}
Successivamente viene 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)
}
Un aspetto importante da notare a questo punto è lo stato di analisi del documento HTML. Il browser non conosce ancora il pulsante "#theme-toggle", perché 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 sarà in grado di impostare solo il tag HTML. L'querySelector
inizialmente non troverà nulla e l'operatore
di concatenamento opzionale
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 l'attributo data-theme del documento HTML sia impostato:
reflectPreference()
Il pulsante richiede comunque l'attributo, quindi attendi l'evento di caricamento pagina, poi sarà possibile 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 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
La sincronizzazione con la preferenza di sistema è una caratteristica unica di questo cambio di tema. Se un utente modifica le preferenze di sistema mentre una pagina e questo componente sono visibili, l'interruttore del tema cambia in base alle nuove preferenze dell'utente, come se l'utente avesse interagito con l'interruttore del tema nello stesso momento in cui ha effettuato il cambio di sistema.
Per farlo, utilizza JavaScript e un
matchMedia
ascolto di eventi per le modifiche a una media query:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
Conclusione
Ora che sai come ho fatto, come faresti tu?‽ 🙂
Diversifichiamo i nostri approcci e impariamo tutti i modi per creare sul web. Crea una demo, inviami un tweet con i link e la aggiungerò alla sezione dei remix della community qui sotto.