Uma visão geral básica de como criar um componente de alternância de tema adaptável e acessível.
Neste post, quero compartilhar uma maneira de criar um componente de alternância entre temas claros e escuros. Teste a demonstração.
Se preferir vídeos, confira a versão desta postagem no YouTube:
Visão geral
Um site pode fornecer configurações para controlar o esquema de cores em vez de depender exclusivamente das preferências 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 ele prefere que o site seja exibido no tema escuro.
Há várias considerações de engenharia da Web ao criar esse recurso. Por exemplo, o navegador precisa ser informado sobre a preferência o mais rápido possível para evitar flashes de cores na página. O controle precisa primeiro sincronizar com o sistema e, em seguida, permitir exceções armazenadas do lado do cliente.
Marcação
Um <button>
precisa ser usado para a alternância, já que você se beneficia dos eventos e recursos de interação
fornecidos pelo navegador, como eventos de clique e foco.
O botão
O botão precisa de uma classe para uso do CSS e de um ID do JavaScript.
Além disso, como o conteúdo do botão é um ícone, e não um texto, adicione um atributo
title
para fornecer informações sobre a finalidade do botão. Por fim, adicione um
[aria-label]
para manter 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
educado
Para indicar aos leitores de tela que as mudanças em 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 sinaliza aos leitores de tela para informar ao usuário o que mudou, em vez de
aria-live="assertive"
. No caso desse botão, ele vai anunciar "claro"
ou "escuro", dependendo do que o aria-label
se tornou.
O ícone de gráfico vetorial escalável (SVG)
O SVG oferece uma maneira de criar formas escalonáveis e de alta qualidade com marcação mínima. Interagir com o botão pode acionar novos estados visuais para os vetores, tornando o SVG ótimo para ícones.
A marcação SVG a seguir vai dentro do <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 ignorá-lo, já que ele está
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 razões semelhantes às que as imagens precisam ter tamanhos
inline.
Sol
O gráfico do sol consiste em um círculo e linhas que o SVG tem formas
para exibir. O <circle>
é centralizado definindo as propriedades cx
e cy
como 12,
que é metade do tamanho da janela de visualização (24), e, em seguida, 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 de elemento
SVG,
que você vai criar em seguida e, por fim, receber uma cor de preenchimento que corresponde à
cor do texto da página com
currentColor
.
Os raios de sol
Em seguida, as linhas do raio de sol são adicionadas logo abaixo do círculo, dentro de um grupo
<g>
de elemento de grupo.
<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>
Dessa vez, em vez de o valor de
fill ser
currentColor
, o
stroke de cada linha é
definido. As linhas e as formas circulares criam um sol com raios.
A lua
Para criar a ilusão de uma transição perfeita entre a luz (sol) e a escura (lua), a lua é uma ampliação do ícone de 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
<circle>
da lua com uma máscara SVG, simplesmente movendo uma forma de círculo para dentro e para fora de uma área
de máscara.
O que acontece se o CSS não carregar?
É bom testar o SVG como se o CSS não tivesse carregado para garantir que o resultado não
seja muito grande ou cause problemas de layout. Os atributos de altura e largura inline no
SVG, além do uso de currentColor
, fornecem regras de estilo mínimas para o navegador
usar se o CSS não carregar. Isso cria bons estilos de defesa contra turbulência
de rede.
Layout
O componente da troca de tema tem pouca área de superfície, então você não precisa de grade ou flexbox para layout. Em vez disso, o posicionamento SVG e as transformações CSS são usados.
Estilos
Estilos .theme-toggle
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 é transformar o botão em um círculo e remover os estilos de botão 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 uma experiência de toque
de reação rápida.
Remove o destaque semitransparente que o iOS aplica aos botões. Por fim, dê ao
desenho do estado de foco um pouco de espaço 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 se ajustar ao tamanho do botão e, para uma aparência suave, arredonde as extremidades 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;
}
}
Tamanho adaptável com a consulta de mídia hover
O tamanho do botão de í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 de
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 alternância de tema, enquanto o SVG dentro dele contém os aspectos visuais e animados. É aqui que o ícone pode ser bonito e ganhar vida.
Tema claro
Para que as animações de dimensionamento e rotação aconteçam a partir do 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 o botão fornecido
var(--icon-fill)
e var(--icon-fill-hover)
para o preenchimento, enquanto os
raios de sol usam as variáveis para 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 da lua precisam remover os raios de sol, aumentar o círculo do sol e mover a máscara do círculo.
.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;
}
}
}
}
O tema escuro não tem mudanças ou transições de cor. O componente de botão pai é proprietário das cores, que já são adaptáveis em um contexto escuro e claro. As informações de transição precisam estar por trás da consulta de mídia de preferência de movimento de um usuário.
Animação
O botão precisa ser funcional e com estado, mas sem transições nesse ponto. As seções a seguir são sobre como definir como e o que faz a transição.
Como compartilhar consultas de mídia e importar transições
Para facilitar a inclusão de transições e animações nas preferências de movimento do sistema operacional de um usuário, o plug-in PostCSS Custom Media permite o uso da sintaxe da especificação de 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 de CSS exclusivos e fáceis de usar, importe a parte de easings das Propostas abertas:
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
Sol
As transições do Sol serão mais divertidas do que a Lua, alcançando esse efeito com transições suaves. Os raios solares saltam um pouco à medida que giram, e o centro do sol oscila um pouco à medida que se dimensiona.
Os estilos padrão (tema claro) definem as transições, e os estilos do tema escuro definem as 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 de 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 luz e escuro da lua 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 clara. Se o sol for eclipsado muito cedo, por exemplo, a transição não parecerá orquestrada ou divertida, mas caótica.
.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;
}
}
}
}
Prefere movimento reduzido
Na maioria dos desafios de GUI, tento manter alguma animação, como crossfades de opacidade, para usuários que preferem movimento reduzido. No entanto, esse componente se sentiu melhor com mudanças de estado instantâneo.
JavaScript
Há muito trabalho para o JavaScript neste componente, desde o gerenciamento de informações ARIA para leitores de tela até a obtenção e configuração de valores do armazenamento local.
Experiência de carregamento da página
É importante que nenhuma cor pisque no carregamento da página. Se um usuário com um
esquema de cores escuro indicar que prefere a luz com esse componente, ele
recarregará a página. No início, a página estará escura e, em seguida, ela vai piscar para a luz.
Para evitar isso, foi necessário executar uma pequena quantidade de JavaScript de bloqueio com o
objetivo de definir o atributo HTML data-theme
o mais cedo possível.
<script src="./theme-toggle.js"></script>
Para 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 este, 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 assim o flash ou as cores.
O JavaScript primeiro verifica a preferência do usuário no armazenamento local e usa o substituto para verificar 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 a seguir:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
Seguido 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 sabe sobre o botão "#theme-toggle",
porque a tag <head>
não foi totalmente analisada. No entanto, o
navegador tem uma
document.firstElementChild
,
também conhecida como tag <html>
. A função tenta definir os dois para manter a sincronização,
mas na primeira execução só será possível definir a tag HTML. O
querySelector
não vai 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 a função setAttribute for
tentada de ser invocada.
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 da página. Depois disso, será seguro 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 recebe um clique, o tema precisa ser trocado na memória JavaScript e no documento. O valor do tema atual precisa ser inspecionado e uma decisão precisa ser tomada sobre o novo estado. Depois que o novo estado for definido, salve e atualize o documento:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
Sincronizar com o sistema
A sincronização com a preferência do sistema é exclusiva para essa troca de tema. Se um usuário mudar a preferência do sistema enquanto uma página e esse componente estão visíveis, a troca 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 que fez a troca do sistema.
Faça isso com 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, como você faria? 🙂
Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, envie links para mim e vou adicionar à 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
- @JoshWComeau com react