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