Como criar um componente de mudança de tema

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.

Demonstração foi aumentado para facilitar a visibilidade

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.

O diagrama mostra uma prévia do carregamento de página em JavaScript e dos eventos de interação de documentos para mostrar que há quatro maneiras de definir o tema.

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 ícone do sol mostrado com os raios desbotados e uma seta rosa-choque apontando para o círculo no centro.

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

O ícone do sol mostrado com o centro desbotado e uma seta rosa-choque apontando para 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>
Gráfico com três camadas verticais para ajudar a mostrar como o mascaramento funciona. A camada de cima é um quadrado branco com um círculo preto. A camada do meio é o ícone de sol.
A camada de baixo é rotulada como o resultado e mostra o ícone do sol com um corte onde está o círculo preto da camada de cima.

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?

Captura de tela de um botão simples do navegador com o ícone do sol dentro.

É 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

ALT_TEXT_HERE

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

ALT_TEXT_HERE

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.

Transição do claro para o escuro
Transição do escuro para o claro

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;
      }
    }
  }
}
Transição do claro para o escuro
Transição do escuro para o claro

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()
  })
Mudar a preferência do sistema do macOS altera o estado da troca de tema

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