Como criar um componente de mudança de tema

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.

O tamanho do botão Demo foi aumentado para facilitar a visualizaçã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.

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

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 ícone do Sol mostrado com os raios de sol desaparecidos e uma seta em 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 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

O ícone do sol mostrado com o centro do sol desbotado e uma seta rosa-choque
  apontando para os raios solares.

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>
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 do sol.
A camada de baixo é identificada 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 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?

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

É 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

ALT_TEXT_HERE

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

ALT_TEXT_HERE

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.

Transición de claro a escuro
Transição de escuro para claro

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;
      }
    }
  }
}
Transição clara para escura
Transição de claro para claro

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()
  })
Alterar a preferência do sistema MacOS muda 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, envie links para mim e vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade