Como criar um componente de mudança de tema

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.

Demonstração aumentou para facilitar a visibilidade

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.

O diagrama mostra uma visualização do carregamento de página JavaScript e dos eventos de interação com o documento para mostrar de modo geral que há quatro caminhos para definir o tema

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

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

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

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?

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

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

ALT_TEXT_HERE

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

ALT_TEXT_HERE

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.

Transição clara para escura
Transição escura para clara

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

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()
  })
Mudar a preferência do sistema MacOS muda o estado da chave do tema

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