Uma visão geral fundamental de como criar um componente de troca de tema adaptável e acessível.
Nesta postagem, quero compartilhar uma maneira de criar um componente de troca de tema claro e escuro. Teste a demonstração.
Se preferir vídeo, confira uma 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 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 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 da preferência o mais rápido possível para evitar flashes de cor na página, e o controle precisa primeiro sincronizar com o sistema e permitir exceções armazenadas do lado do cliente.

Marcação
Um <button>
deve ser usado para a alternância, já que você se beneficia de 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 em CSS e um ID para uso em 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 manter o estado do botão de í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 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 de forma educada, 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 elemento gráfico vetorial escalável (SVG)
O SVG oferece uma maneira de criar formas escalonáveis de alta qualidade com marcação mínima. A interação com o botão pode acionar novos estados visuais para os vetores, o que torna o SVG ótimo para ícones.
A seguinte marcação SVG fica 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 motivos semelhantes para que as imagens tenham tamanhos
inline.
Sol
O gráfico do sol consiste em um círculo e linhas, que o SVG tem formas convenientes. O <circle>
é centralizado definindo as propriedades cx
e cy
como 12, metade do tamanho da janela de visualização (24), e recebendo 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. Por fim, ela recebe uma cor de preenchimento que corresponde à cor do texto da página com currentColor
.
Os raios de sol
Em seguida, as linhas de raio de sol são adicionadas logo abaixo do círculo, dentro de um elemento
de grupo <g>
group.
<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 os círculos criam um sol bonito com raios.
A lua
Para criar a ilusão de uma transição perfeita entre a luz (sol) e a escuridão (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 branca e preta removam ou incluam partes de outro gráfico. O ícone de sol será eclipsado por uma forma de lua
<circle>
com uma máscara SVG. Basta mover um círculo para dentro e para fora de uma área de máscara.
O que acontece se o CSS não for carregado?

É bom testar o SVG como se o CSS não fosse 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
, oferecem regras de estilo mínimas para o navegador usar se o CSS não for carregado. Isso cria estilos defensivos interessantes contra turbulências de rede.
Layout
O componente de troca de tema tem pouca área de superfície, então não é necessário usar grade ou flexbox para o layout. Em vez disso, são usadas transformações CSS e posicionamento SVG.
Estilos
.theme-toggle
estilos
O elemento <button>
é o contêiner para as formas e estilos de ícones. Esse contexto
principal 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 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 com reação rápida.
Remova o destaque semitransparente que o iOS aplica aos botões. Por fim, dê ao contorno 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 suavidade visual, arredondar 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;
}
}
Dimensionamento 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 grosso, 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 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 interno contém os aspectos visuais e animados. É aqui que o ícone pode ser deixado bonito e ganhar vida.
Tema claro

Para que as animações de dimensionamento e rotação aconteçam 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 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 da lua precisam remover os raios de sol, aumentar o círculo do sol 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: 1px) {
transform: translateX(0);
cx: 17px;
}
}
}
}
O tema escuro não tem mudanças de cor nem transições. O componente do botão principal possui as cores, que já são adaptáveis em um contexto escuro e claro. As informações de transição precisam estar atrás de uma 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 neste ponto. As seções a seguir são sobre como definir como e o que transições.
Compartilhamento de consultas de mídia e importação de suavizações
Para facilitar a colocação de transições e animações por trás das preferências de movimento do sistema operacional de um usuário, o plug-in PostCSS CustomMedia permite o uso da sintaxe especificação CSS provisória 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 usar suavizações exclusivas e fáceis de usar do CSS, importe a parte easings do Open Props:
@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 as da lua, alcançando esse efeito com suavizações dinâmicas. Os raios de sol precisam quicar um pouco enquanto giram, e o centro do sol precisa quicar um pouco enquanto é dimensionado.
Os estilos padrão (tema claro) definem as transições, e os estilos do 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 Animação do Chrome DevTools, você encontra uma linha do tempo para transições de animação. A duração da animação total, dos elementos e da função de aceleração pode ser inspecionada.


A lua
As posições clara e escura 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 é fundamental para tornar essa transição limpa. Se o sol for eclipsado muito cedo, por exemplo, a transição não vai 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 transições de opacidade, para usuários que preferem movimento reduzido. No entanto, esse componente funcionou melhor com mudanças de estado instantâneas.
JavaScript
Há muito trabalho para o JavaScript nesse componente, desde o gerenciamento de informações ARIA para leitores de tela até a obtenção e definição de valores do armazenamento local.
A experiência de carregamento da página
Era importante que não houvesse cores piscando no carregamento da página. Se um usuário com um
esquema de cores escuras indicar que prefere claro com esse componente e
recarregar a página, ela vai aparecer escura e depois vai piscar para clara.
Para evitar isso, era necessário executar uma pequena quantidade de JavaScript de bloqueio com o
objetivo de definir o atributo HTML data-theme
o quanto antes.
<script src="./theme-toggle.js"></script>
Para isso, uma tag <script>
simples no documento <head>
é carregada
primeiro, antes de qualquer CSS ou marcação <body>
. Quando o navegador encontra um
script não marcado como este, ele executa o código 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 um flash ou
cores.
Primeiro, o JavaScript verifica a preferência do usuário no armazenamento local e volta a 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'
}
Em seguida, uma função para definir a preferência do usuário no armazenamento local é analisada:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
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)
}
Neste ponto, é importante observar o estado de análise do documento HTML. O navegador ainda não conhece o botão "#theme-toggle", já que 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 os dois para mantê-los sincronizados, mas na primeira execução só consegue definir a tag HTML. O
querySelector
não vai encontrar nada a princípio, e o operador de
encadeamento opcional
garante que não haja erros de sintaxe quando ele não for encontrado e a função setAttribute
tentar ser invocada.
Em seguida, a 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 é clicado, 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 dele. Depois que o novo estado for definido, salve e atualize o documento:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
Sincronização com o sistema
A sincronização com a preferência do sistema conforme ela muda é exclusiva dessa troca de tema. Se um usuário mudar a preferência do sistema enquanto uma página e esse componente estiverem visíveis, a troca de tema vai corresponder à nova preferência do usuário, como se ele tivesse interagido com a troca de tema ao mesmo tempo em que fez a troca de sistema.
Para fazer isso, use JavaScript e um
matchMedia
listener de eventos para detectar 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, me envie um tweet com o link, e eu 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 puro
- @JoshWComeau com react