Como criar um componente de switch

Uma visão geral básica de como criar um componente de interruptor responsivo e acessível.

Neste post, quero compartilhar uma maneira de criar componentes de chave. Teste a demonstração.

Demo

Se preferir vídeos, confira a versão desta postagem no YouTube:

Visão geral

Um switch funciona de forma semelhante a uma caixa de seleção, mas representa explicitamente os estados booleanos ativado e desativado.

Essa demonstração usa <input type="checkbox" role="switch"> para a maioria das funcionalidades, que tem a vantagem de não precisar de CSS ou JavaScript para ser totalmente funcional e acessível. O carregamento de CSS oferece suporte a idiomas da direita para a esquerda, verticalidade, animação e muito mais. O carregamento do JavaScript torna o botão arrastável e tangível.

Propriedades personalizadas

As variáveis a seguir representam as várias partes da chave e as opções dela. Como a classe de nível superior, .gui-switch contém propriedades personalizadas usadas em todos os filhos dos componentes e pontos de entrada para personalização centralizada.

Faixa

O comprimento (--track-size), o padding e duas cores:

.gui-switch {
  --track-size: calc(var(--thumb-size) * 2);
  --track-padding: 2px;

  --track-inactive: hsl(80 0% 80%);
  --track-active: hsl(80 60% 45%);

  --track-color-inactive: var(--track-inactive);
  --track-color-active: var(--track-active);

  @media (prefers-color-scheme: dark) {
    --track-inactive: hsl(80 0% 35%);
    --track-active: hsl(80 60% 60%);
  }
}

Miniatura

O tamanho, a cor do plano de fundo e as cores de destaque de interação:

.gui-switch {
  --thumb-size: 2rem;
  --thumb: hsl(0 0% 100%);
  --thumb-highlight: hsl(0 0% 0% / 25%);

  --thumb-color: var(--thumb);
  --thumb-color-highlight: var(--thumb-highlight);

  @media (prefers-color-scheme: dark) {
    --thumb: hsl(0 0% 5%);
    --thumb-highlight: hsl(0 0% 100% / 25%);
  }
}

Movimento reduzido

Para adicionar um alias claro e reduzir a repetição, uma consulta de mídia de preferência de movimento reduzida do usuário pode ser colocada em uma propriedade personalizada com o plug-in PostCSS com base neste rascunho de especificação em consultas de mídia 5:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

Marcação

Escolhi agrupar meu elemento <input type="checkbox" role="switch"> com um <label>, agrupando a relação deles para evitar a ambiguidade de associação de caixas de seleção e rótulos, além de permitir que o usuário interaja com o rótulo para alternar a entrada.

Um
rótulo natural e sem estilo e uma caixa de seleção.

<label for="switch" class="gui-switch">
  Label text
  <input type="checkbox" role="switch" id="switch">
</label>

O <input type="checkbox"> vem pré-criado com uma API e um estado. O navegador gerencia a propriedade checked e os eventos de entrada, como oninpute onchanged.

Layouts

Flexbox, grid e propriedades personalizadas são essenciais para manter os estilos desse componente. Eles centralizam valores, dão nomes a cálculos ou áreas ambíguas e ativam uma pequena API de propriedade personalizada para personalizar facilmente os componentes.

.gui-switch

O layout de nível superior do interruptor é flexbox. A classe .gui-switch contém as propriedades personalizadas particulares e públicas que os filhos usam para calcular os layouts deles.

As ferramentas do Flexbox DevTools sobrepõem um rótulo horizontal e um botão, mostrando a distribuição
de espaço do layout.

.gui-switch {
  display: flex;
  align-items: center;
  gap: 2ch;
  justify-content: space-between;
}

Estender e modificar o layout flexbox é como mudar qualquer layout flexbox. Por exemplo, para colocar rótulos acima ou abaixo de um interruptor ou para mudar o flex-direction:

As ferramentas do desenvolvedor Flexbox sobrepondo um rótulo vertical e um interruptor.

<label for="light-switch" class="gui-switch" style="flex-direction: column">
  Default
  <input type="checkbox" role="switch" id="light-switch">
</label>

Faixa

A entrada da caixa de seleção é estilizada como uma faixa de alternância, removendo o appearance: checkbox normal e fornecendo o próprio tamanho:

As ferramentas de desenvolvimento de grade sobrepondo a faixa de chave, mostrando as áreas de faixa de grade nomeadas
com o nome &quot;track&quot;.

.gui-switch > input {
  appearance: none;

  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  padding: var(--track-padding);

  flex-shrink: 0;
  display: grid;
  align-items: center;
  grid: [track] 1fr / [track] 1fr;
}

A faixa também cria uma área de faixa de grade de uma única célula para um polegar reivindicar.

Miniatura

O estilo appearance: none também remove a marca de seleção visual fornecida pelo navegador. Esse componente usa um pseudoelemento e a pseudoclasse :checked na entrada para substituir esse indicador visual.

O ícone é um pseudoelemento filho anexado ao input[type="checkbox"] e é empilhado na parte de cima da faixa, em vez de abaixo dela, reivindicando a área de grade track:

DevTools mostrando o pseudoelemento thumb como posicionado dentro de uma grade CSS.

.gui-switch > input::before {
  content: "";
  grid-area: track;
  inline-size: var(--thumb-size);
  block-size: var(--thumb-size);
}

Estilos

As propriedades personalizadas permitem um componente de alternância versátil que se adapta a esquemas de cores, idiomas da direita para a esquerda e preferências de movimento.

Comparação lado a lado do tema claro e escuro do interruptor e dos
estados dele.

Estilos de interação de toque

Em dispositivos móveis, os navegadores adicionam destaques de toque e recursos de seleção de texto a rótulos e entradas. Isso afetou negativamente o estilo e o feedback da interação visual que essa mudança precisava. Com algumas linhas de CSS, posso remover esses efeitos e adicionar meu próprio estilo cursor: pointer:

.gui-switch {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

Nem sempre é recomendável remover esses estilos, já que eles podem ser um feedback visual de interação valioso. Se você remover os campos, forneça alternativas personalizadas.

Faixa

Os estilos desse elemento são principalmente sobre a forma e a cor, que são acessadas do .gui-switch pai pela cascata.

As variantes de chave com tamanhos e cores de faixa personalizados.

.gui-switch > input {
  appearance: none;
  border: none;
  outline-offset: 5px;
  box-sizing: content-box;

  padding: var(--track-padding);
  background: var(--track-color-inactive);
  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  border-radius: var(--track-size);
}

Uma grande variedade de opções de personalização para a faixa de troca vem de quatro propriedades personalizadas. border: none foi adicionado, já que appearance: none não remove as bordas da caixa de seleção em todos os navegadores.

Miniatura

O elemento "thumb" já está à direita da track, mas precisa de estilos de círculo:

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

Exibição do DevTools destacando o pseudoelemento de círculo.

Interação

Use propriedades personalizadas para se preparar para interações que vão mostrar destaques de passagem do cursor e mudanças na posição do polegar. A preferência do usuário também é marcada antes da transição dos estilos de destaque de movimento ou de destaque ao passar o cursor.

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

Posição do polegar

As propriedades personalizadas fornecem um mecanismo de origem único para posicionar o polegar na faixa. Temos à nossa disposição os tamanhos de faixa e polegar que vamos usar nos cálculos para manter o polegar devidamente deslocado e entre a faixa: 0% e 100%.

O elemento input é proprietário da variável de posição --thumb-position, e o pseudoelemento polegar o usa como uma posição translateX:

.gui-switch > input {
  --thumb-position: 0%;
}

.gui-switch > input::before {
  transform: translateX(var(--thumb-position));
}

Agora podemos mudar --thumb-position do CSS e as pseudoclasses fornecidas nos elementos de caixa de seleção. Como já definimos transition: transform var(--thumb-transition-duration) ease condicionalmente nesse elemento, essas mudanças podem ser animadas quando mudam:

/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
}

/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
}

Achei que essa orquestração desassociada funcionou bem. O elemento de miniatura se preocupa apenas com um estilo, uma posição translateX. A entrada pode gerenciar toda a complexidade e os cálculos.

Vertical

O suporte foi feito com uma classe modificadora -vertical, que adiciona uma rotação com transformações CSS ao elemento input.

Um elemento girado em 3D não muda a altura geral do componente, o que pode alterar o layout do bloco. Considere isso usando as variáveis --track-size e --track-padding. Calcule a quantidade mínima de espaço necessária para que um botão vertical flua no layout conforme o esperado:

.gui-switch.-vertical {
  min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

  & > input {
    transform: rotate(-90deg);
  }
}

(RTL) Da direita para a esquerda

Um amigo do CSS, Elad Schecter, e eu criamos juntos um menu lateral deslizante com transformações CSS que lidam com idiomas da direita para a esquerda ao inverter uma única variável. Fizemos isso porque não há transformações de propriedade lógica no CSS e talvez nunca haja. Elad teve a grande ideia de usar um valor de propriedade personalizada para inverter porcentagens, a fim de permitir o gerenciamento de um único local da nossa própria lógica personalizada para transformações lógicas. Usei essa mesma técnica nessa troca e acho que funcionou muito bem:

.gui-switch {
  --isLTR: 1;

  &:dir(rtl) {
    --isLTR: -1;
  }
}

Uma propriedade personalizada chamada --isLTR inicialmente armazena um valor de 1, o que significa que ela é true, já que nosso layout é da esquerda para a direita por padrão. Em seguida, usando a pseudoclasse CSS :dir(), o valor é definido como -1 quando o componente está em um layout da direita para a esquerda.

Use --isLTR em uma transformação usando-o em uma calc():

.gui-switch.-vertical > input {
  transform: rotate(-90deg);
  transform: rotate(calc(90deg * var(--isLTR) * -1));
}

Agora a rotação do botão vertical considera a posição do lado oposto necessária para o layout da direita para a esquerda.

As transformações translateX no pseudoelemento do polegar também precisam ser atualizadas para considerar o requisito do lado oposto:

.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
  --thumb-position: calc(
   ((var(--track-size) / 2) - (var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

Embora essa abordagem não resolva todas as necessidades relacionadas a um conceito como as transformações lógicas do CSS, ela oferece alguns princípios DRY para muitos casos de uso.

Estados

O uso do input[type="checkbox"] integrado não seria completo sem processar os vários estados em que ele pode estar: :checked, :disabled, :indeterminate e :hover. O :focus foi intencionalmente deixado sozinho, com um ajuste feito apenas no deslocamento. O anel de foco ficou ótimo no Firefox e no Safari:

Captura de tela do anel de foco focado em uma chave no Firefox e no Safari.

Marcado

<label for="switch-checked" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>

Esse estado representa o estado on. Nesse estado, o plano de fundo da "faixa" de entrada é definido como a cor ativa, e a posição do círculo é definida como "o fim".

.gui-switch > input:checked {
  background: var(--track-color-active);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

Desativado

<label for="switch-disabled" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>

Um botão :disabled não apenas parece diferente visualmente, mas também precisa tornar o elemento imutável. A imutabilidade de interação é livre do navegador, mas os estados visuais precisam de estilos devido ao uso de appearance: none.

.gui-switch > input:disabled {
  cursor: not-allowed;
  --thumb-color: transparent;

  &::before {
    cursor: not-allowed;
    box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);

    @media (prefers-color-scheme: dark) { & {
      box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
    }}
  }
}

O botão de estilo escuro nos estados desativado, marcado e desmarcado.

Esse estado é complicado, porque precisa de temas escuros e claros, com os estados desativado e marcado. Escolhi estilos mínimos para esses estados para facilitar a manutenção das combinações de estilos.

Indeterminado

Um estado esquecido com frequência é :indeterminate, em que uma caixa de seleção não está marcada ou desmarcada. É um estado divertido, convidativo e despretensioso. Um bom lembrete de que os estados booleanos podem ter estados ocultos entre eles.

É difícil definir uma caixa de seleção como indeterminada, apenas o JavaScript pode fazer isso:

<label for="switch-indeterminate" class="gui-switch">
  Indeterminate
  <input type="checkbox" role="switch" id="switch-indeterminate">
  <script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>

O estado indeterminado, que tem o ícone da faixa no
meio, para indicar que a decisão ainda não foi tomada.

Como o estado é simples e convidativo, achei apropriado colocar a posição do polegar do interruptor no meio:

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

Passar cursor

As interações com o cursor precisam oferecer suporte visual à interface conectada e direcionar a interface interativa. Essa opção destaca o polegar com um anel semitransparente quando o ponteiro do mouse passa por ele. Essa animação de passar o cursor indica a direção do elemento de miniatura interativo.

O efeito "destaque" é feito com box-shadow. Ao passar o cursor, de uma entrada não desativada, aumente o tamanho de --highlight-size. Se o usuário aceitar a animação, faremos a transição da box-shadow e vamos observar o crescimento dela. Se o usuário não aceitar a animação, o destaque vai aparecer instantaneamente:

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

.gui-switch > input:not(:disabled):hover::before {
  --highlight-size: .5rem;
}

JavaScript

Para mim, uma interface de chave pode parecer estranha na tentativa de emular uma interface física, especialmente com um círculo dentro de uma faixa. O iOS acertou com a chave, que pode ser arrastada para os lados, e é muito satisfatório ter essa opção. Por outro lado, um elemento da interface pode parecer inativo se um gesto de arrastar for tentado e nada acontecer.

Polegar arrastável

O pseudoelemento do polegar recebe a posição do var(--thumb-position) com escopo .gui-switch > input. O JavaScript pode fornecer um valor de estilo inline na entrada para atualizar dinamicamente a posição do polegar, fazendo com que ele pareça seguir o gesto do ponteiro. Quando o ponteiro for liberado, remova os estilos inline e determine se o arrasto estava mais próximo de "desativado" ou "ativado" usando a propriedade personalizada --thumb-position. Essa é a base da solução: eventos de ponteiro rastreando condicionalmente as posições do ponteiro para modificar as propriedades personalizadas do CSS.

Como o componente já estava 100% funcional antes de esse script aparecer, é necessário um pouco de trabalho para manter o comportamento atual, como clicar em um rótulo para alternar a entrada. Nosso JavaScript não pode adicionar recursos à custa dos recursos atuais.

touch-action

Arrastar é um gesto personalizado, o que o torna uma ótima opção para os benefícios de touch-action. No caso dessa chave, um gesto horizontal precisa ser processado pelo nosso script ou um gesto vertical capturado para a variante de chave vertical. Com touch-action, podemos informar ao navegador quais gestos processar neste elemento. Assim, um script pode processar um gesto sem competição.

O CSS a seguir instrui o navegador que, quando um gesto de ponteiro começar dentro dessa faixa de alternância, processar gestos verticais, não fazer nada com os horizontais:

.gui-switch > input {
  touch-action: pan-y;
}

O resultado desejado é um gesto horizontal que não desliza ou rola a página. Um ponteiro pode rolar verticalmente a partir da entrada e rolar a página, mas os horizontais são processados de forma personalizada.

Utilitários de estilo de valor de pixel

Na configuração e durante a ação de arrastar, vários valores numéricos calculados precisam ser extraídos dos elementos. As funções JavaScript a seguir retornam valores de pixels calculados com base em uma propriedade CSS. Ele é usado no script de configuração como este getStyle(checkbox, 'padding-left').

​​const getStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}

const getPseudoStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}

export {
  getStyle,
  getPseudoStyle,
}

Observe como window.getComputedStyle() aceita um segundo argumento, um pseudoelemento de destino. É muito legal que o JavaScript possa ler tantos valores de elementos, até mesmo de pseudoelementos.

dragging

Esse é um momento importante para a lógica de arrasto, e há algumas coisas a serem observadas no manipulador de eventos da função:

const dragging = event => {
  if (!state.activethumb) return

  let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality = getStyle(state.activethumb, '--isLTR')

  let track = (directionality === -1)
    ? (state.activethumb.clientWidth * -1) + thumbsize + padding
    : 0

  let pos = Math.round(event.offsetX - thumbsize / 2)

  if (pos < bounds.lower) pos = 0
  if (pos > bounds.upper) pos = bounds.upper

  state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}

O herói do script é state.activethumb, o pequeno círculo que o script está posicionando com um ponteiro. O objeto switches é um Map() em que as chaves são .gui-switch e os valores são limites e tamanhos armazenados em cache que mantêm o script eficiente. A direção direita-esquerda é processada usando a mesma propriedade personalizada que o CSS é --isLTR e pode ser usada para inverter a lógica e continuar oferecendo suporte a RTL. O event.offsetX também é valioso, porque contém um valor delta útil para posicionar o círculo.

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

Essa linha final do CSS define a propriedade personalizada usada pelo elemento de miniatura. Essa atribuição de valor seria transferida ao longo do tempo, mas um evento de ponteiro anterior definiu temporariamente --thumb-transition-duration como 0s, removendo o que teria sido uma interação lenta.

dragEnd

Para que o usuário possa arrastar para fora da chave e soltar, um evento de janela global precisa ser registrado:

window.addEventListener('pointerup', event => {
  if (!state.activethumb) return

  dragEnd(event)
})

Acho muito importante que o usuário tenha liberdade para arrastar com flexibilidade e que a interface seja inteligente o suficiente para dar conta disso. Não foi necessário muito esforço para lidar com essa mudança, mas foi necessário uma consideração cuidadosa durante o processo de desenvolvimento.

const dragEnd = event => {
  if (!state.activethumb) return

  state.activethumb.checked = determineChecked()

  if (state.activethumb.indeterminate)
    state.activethumb.indeterminate = false

  state.activethumb.style.removeProperty('--thumb-transition-duration')
  state.activethumb.style.removeProperty('--thumb-position')
  state.activethumb.removeEventListener('pointermove', dragging)
  state.activethumb = null

  padRelease()
}

A interação com o elemento foi concluída. É hora de definir a propriedade de entrada marcada e remover todos os eventos de gesto. A caixa de seleção é alterada com state.activethumb.checked = determineChecked().

determineChecked()

Essa função, chamada por dragEnd, determina onde o polegar está dentro dos limites da faixa e retorna verdadeiro se for igual ou superior à metade da faixa:

const determineChecked = () => {
  let {bounds} = switches.get(state.activethumb.parentElement)

  let curpos =
    Math.abs(
      parseInt(
        state.activethumb.style.getPropertyValue('--thumb-position')))

  if (!curpos) {
    curpos = state.activethumb.checked
      ? bounds.lower
      : bounds.upper
  }

  return curpos >= bounds.middle
}

Mais ideias

O gesto de arrastar gerou um pouco de código devido à estrutura HTML inicial escolhida, principalmente em torno da entrada em um rótulo. O rótulo, sendo um elemento pai, receberia interações de clique após a entrada. No final do evento dragEnd, você pode ter notado padRelease() como uma função de som estranho.

const padRelease = () => {
  state.recentlyDragged = true

  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}

Isso é para considerar o rótulo que recebe esse clique posterior, já que ele desmarcaria ou marcaria a interação realizada por um usuário.

Se eu tivesse que fazer isso de novo, poderia ajustar o DOM com JavaScript durante o upgrade de UX para criar um elemento que processa os cliques de rótulo e não conflita com o comportamento integrado.

Esse tipo de JavaScript é o meu menos favorito para escrever. Não quero gerenciar o evento condicional de bolhas:

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

Conclusão

Esse componente de chave pequena acabou sendo o mais trabalhoso de todos os desafios de GUI até agora! 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 um tweet para mim (link em inglês) e eu vou adicionar o conteúdo à seção de remixes da comunidade abaixo.

Remixes da comunidade

Recursos

Encontre o código-fonte do .gui-switch no GitHub.