Uma visão geral básica de como criar um componente de mudança de tema adaptável e acessível.
Nesta postagem, quero compartilhar ideias sobre como criar um componente de seleção de tema claro e escuro. Teste a demonstração.
Se preferir vídeo, aqui está uma versão do YouTube desta postagem:
Visão geral
Um site pode fornecer configurações para controlar o esquema de cores em vez de depender totalmente da preferência do sistema. Isso significa que os usuários podem navegar em um modo diferente das preferências do sistema. Por exemplo, o sistema de um usuário está em um tema claro, mas o usuário prefere que o site seja mostrado no tema escuro.
Há várias considerações de engenharia da Web ao criar esse recurso. Por exemplo, o navegador precisa estar ciente da preferência o mais rápido possível para evitar a mudança de cores na página. Além disso, o controle precisa primeiro sincronizar com o sistema e depois permitir exceções armazenadas do lado do cliente.
Marcação
Uma <button>
precisa ser usada para alternar, já que você aproveita os eventos e recursos de interação
fornecidos pelo navegador, como eventos de clique e capacidade de foco.
O botão
O botão precisa de uma classe para uso do CSS e um ID para uso do JavaScript.
Além disso, como o conteúdo do botão é um ícone em vez de texto, adicione um
atributo title
para fornecer informações sobre a finalidade do botão. Por fim, adicione um
[aria-label]
para armazenar o estado do botão do ícone. Assim, os leitores de tela podem compartilhar o estado do
tema com pessoas com deficiência visual.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
>
…
</button>
aria-label
e aria-live
educados
Para indicar aos leitores de tela que as mudanças para aria-label
precisam ser anunciadas,
adicione
aria-live="polite"
ao botão.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
…
</button>
Essa adição de marcação indica aos leitores de tela para informar educadamente ao usuário o que mudou em vez de
aria-live="assertive"
. No caso desse botão, ele anunciará "claro"
ou "escuro", dependendo de como a aria-label
tiver se tornado.
O ícone do gráfico vetorial escalável (SVG)
O SVG oferece uma maneira de criar formas escalonáveis e de alta qualidade com o mínimo de marcação. A interação com o botão pode acionar novos estados visuais para os vetores, tornando o SVG ótimo para ícones.
A marcação SVG abaixo aparece no <button>
:
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
…
</svg>
aria-hidden
foi adicionado ao elemento SVG para que os leitores de tela saibam que ele deve ser ignorado por ser
marcado como de apresentação. Isso é ótimo para decorações visuais, como o ícone
dentro de um botão. Além do atributo viewBox
obrigatório no elemento,
adicione altura e largura por motivos parecidos para que as imagens tenham tamanhos
in-line.
O sol
O gráfico do sol consiste em um círculo e linhas para as quais o SVG tem formas convenientes. A <circle>
é centralizada definindo as propriedades cx
e cy
como 12,
que é metade do tamanho da janela de visualização (24), e recebe um raio (r
) de 6
que define o tamanho.
<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>
Além disso, a propriedade de máscara aponta para um ID do elemento
SVG,
que você vai criar em seguida e, por fim, receber uma cor de preenchimento que corresponda à
cor de texto da página com
currentColor
.
Os raios solares
Em seguida, as linhas de raios solares são adicionadas logo abaixo do círculo, dentro de um grupo
de elemento de grupo
<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>
Desta vez, em vez de o valor de fill ser currentColor
, o traço de cada linha é definido. As linhas e as formas circulares criam um belo sol com vigas.
A lua
Para criar a ilusão de uma transição perfeita entre luz (sol) e escura (lua), a lua é uma ampliação do ícone do sol, usando uma máscara 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>
As máscaras com SVG
são poderosas, permitindo que as cores branco e preto removam ou incluam
partes de outro gráfico. O ícone do sol será eclipsado por uma forma de
lua <circle>
com uma máscara SVG, simplesmente movendo uma forma de círculo para dentro e para fora de uma área
da máscara.
O que acontece se o CSS não carregar?
Pode ser bom testar o SVG como se o CSS não fosse carregado para garantir que o resultado não seja
muito grande ou causando problemas de layout. Os atributos de altura e largura inline no
SVG e o uso de currentColor
fornecem regras mínimas de estilo para o navegador
usar se o CSS não carregar. Isso cria estilos defensivos
contra turbulências de rede.
Layout
O componente de troca de tema tem pouca área de superfície. Por isso, não é necessário usar grade ou flexbox para o layout. Em vez disso, são usados o posicionamento SVG e transformações CSS.
Estilos
.theme-toggle
estilos
O elemento <button>
é o contêiner das formas e estilos do ícone. Esse
contexto pai vai conter cores e tamanhos adaptáveis para transmitir ao SVG.
A primeira tarefa é tornar o botão um círculo e remover os estilos padrão:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
}
Em seguida, adicione alguns estilos de interação. Adicione um estilo de cursor para usuários de mouse. Adicione
touch-action: manipulation
para ter uma experiência de toque
rápida.
Remover o destaque semitransparente que o iOS aplica aos botões. Por fim, dê ao
estado de foco um pouco de espaço para respirar a partir da borda do 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;
}
O SVG dentro do botão também precisa de alguns estilos. O SVG precisa caber no tamanho do botão e, para suavidade visual, arredondar os finais da linha:
.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 adaptável com a consulta de mídia hover
.
O tamanho do botão do ícone é um pouco pequeno em 2rem
, o que é bom para usuários de mouse, mas
pode ser difícil para um ponteiro grosseiro como um dedo. Faça com que o botão atenda a muitas
diretrizes de tamanho
de toque
usando uma consulta de mídia
ao passar o cursor para especificar
um aumento de tamanho.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Estilos SVG de sol e lua
O botão contém os aspectos interativos do componente de troca de tema, enquanto o SVG dentro mantém os aspectos visuais e animados. É aqui que o ícone pode ser feito bonito e ganhar vida.
Tema claro
Para que as animações de dimensionamento e rotação aconteçam no centro das formas SVG, defina
o transform-origin: center center
delas. As cores adaptáveis fornecidas pelo
botão são usadas aqui pelas formas. A lua e o sol usam os botões var(--icon-fill)
e var(--icon-fill-hover)
para o preenchimento, enquanto os raios solares usam as variáveis para o traço.
.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 escuro
Os estilos lua precisam remover os raios solares, ampliar o círculo solar e mover a máscara circular.
.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;
}
}
}
}
O tema escuro não tem mudanças de cor ou transições. O componente do botão pai é proprietário das cores, onde elas já são adaptáveis em um contexto claro e escuro. As informações de transição precisam estar por trás da consulta de mídia de preferência de movimento do usuário.
Animação
O botão precisa ser funcional e com estado, mas sem transições nesse ponto. As seções a seguir abordam a definição de como e o que faz as transições.
Como compartilhar consultas de mídia e importar easings
Para facilitar a colocação de transições e animações nas preferências de movimento do sistema operacional de um usuário, o plug-in Custom Media do plug-in PostCSS permite o uso da sintaxe de especificação CSS redigida para variáveis de consulta de mídia:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
Para easings CSS exclusivos e fáceis de usar, importe a parte easings dos Open Props:
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
O sol
As transições do sol serão mais divertidas do que a lua, atingindo esse efeito com efeitos de easing. Os raios solares saltam um pouco conforme giram, e o centro do sol saliente um pouco conforme ele dimensiona.
Os estilos padrão (tema claro) definem as transições, e os estilos de tema escuro definem personalizações para a transição para o claro:
.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;
}
}
}
}
No painel Animation do Chrome DevTools, há uma linha do tempo para as transições de animação. A duração da animação total, os elementos e o tempo de easing podem ser inspecionados.
A lua
As posições de lua e escura já estão definidas. Adicione estilos de transição dentro
da consulta de mídia --motionOK
para dar vida a ela, respeitando as
preferências de movimento do usuário.
O tempo com atraso e duração são essenciais para tornar essa transição limpa. Se o sol for eclipsado muito cedo, por exemplo, a transição não parece orquestrada ou divertida, mas sim caótica.
.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;
}
}
}
}
Prefere movimento reduzido
Na maioria dos desafios de GUI, tento manter algumas animações, como o esmaecimento cruzado de opacidade, para usuários que preferem movimentos reduzidos. No entanto, esse componente ficou melhor com mudanças instantâneas de estado.
JavaScript
Esse componente exige muito trabalho para o JavaScript, desde gerenciar informações ARIA para leitores de tela até receber e definir valores do armazenamento local.
A experiência de carregamento da página
Era importante que nenhuma cor piscasse de cor durante o carregamento da página. Se um usuário com um
esquema de cores escuras indicar que prefere a luz com esse componente e, em seguida,
recarregar a página, ela inicialmente ficará escura e, em seguida, ela piscará para claro.
Para evitar isso, era preciso executar uma pequena quantidade de bloqueio de JavaScript com o
objetivo de definir o atributo HTML data-theme
o quanto antes.
<script src="./theme-toggle.js"></script>
Para fazer isso, uma tag <script>
simples no documento <head>
é carregada
primeiro, antes de qualquer marcação CSS ou <body>
. Quando o navegador encontra um script não marcado como esse, ele executa o código e o executa antes do restante do HTML. Usando esse momento de bloqueio com moderação, é possível definir o atributo HTML
antes que o CSS principal pinte a página, evitando o flash ou
as cores.
Primeiro, o JavaScript verifica a preferência do usuário no armazenamento local e, em seguida, verifica a preferência do sistema se nada for encontrado no armazenamento:
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'
}
Uma função para definir a preferência do usuário no armazenamento local é analisada em seguida:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
A função é seguida por uma função para modificar o documento com as preferências.
const reflectPreference = () => {
document.firstElementChild
.setAttribute('data-theme', theme.value)
document
.querySelector('#theme-toggle')
?.setAttribute('aria-label', theme.value)
}
Uma coisa importante a notar neste ponto é o estado de análise do documento HTML. O navegador ainda não conhece o botão "#theme-toggle",
porque a tag <head>
não foi totalmente analisada. No entanto, o navegador tem um document.firstElementChild
, também conhecido como tag <html>
. A função tenta definir ambos para mantê-los sincronizados, mas, na primeira execução, só poderá definir a tag HTML. O
querySelector
não encontrará nada no início, e o operador de
encadeamento opcional
garante que não haja erros de sintaxe quando não for encontrado e se houver uma
tentativa de invocar a função setAttribute.
Em seguida, essa função reflectPreference()
é chamada imediatamente para que o documento
HTML tenha o atributo data-theme
definido:
reflectPreference()
O botão ainda precisa do atributo. Portanto, aguarde o evento de carregamento de página para consultar, adicionar listeners e definir atributos em:
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)
}
A experiência de alternância
Quando o botão é clicado, o tema precisa ser trocado na memória JavaScript e no documento. O valor do tema atual vai precisar ser inspecionado e uma decisão sobre o novo estado dele. Depois de definir o novo estado, salve-o e atualize o documento:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
Como sincronizar com o sistema
Exclusivo dessa opção de tema é a sincronização com a preferência do sistema conforme ela muda. Se um usuário mudar a preferência do sistema enquanto uma página e esse componente estiverem visíveis, a opção de tema vai mudar para corresponder à nova preferência do usuário, como se o usuário tivesse interagido com a troca de tema ao mesmo tempo.
Faça isso com o JavaScript e um evento
matchMedia
que detecta mudanças em uma consulta de mídia:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
Conclusão
Agora que você sabe como eu fiz isso, o que você faria ‽ 🙂
Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, envie um tweet para mim e os adicionarei à seção de remixes da comunidade abaixo.
Remixes da comunidade
- @NathanG no Codepen com Vue
- @ShadowShahriar no Codepen
- @tomayac como um elemento personalizado
- @bramus com JavaScript baunilha
- @JoshWComeau com react