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 interruptor funciona de forma semelhante a uma caixa de seleção, mas representa explicitamente os estados ativado e desativado booleanos.

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 de JavaScript torna a chave arrastável e tangível.

Propriedades personalizadas

As variáveis a seguir representam as várias partes do switch e as opções dele. Como a classe de nível superior, .gui-switch contém propriedades personalizadas usadas em todos os componentes filhos e pontos de entrada para a 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 e uma caixa de seleção naturais, sem estilo.

<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

As propriedades Flexbox, grade e 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 públicas e particulares que os filhos usam para calcular os layouts.

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 alterar 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 de caixa de seleção é estilizada como uma faixa de chave removendo o appearance: checkbox normal e fornecendo o próprio tamanho:

Grid DevTools sobrepondo a faixa de alternância, mostrando as áreas da 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:

As ferramentas do desenvolvedor mostram o esboço do pseudoelemento 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 de interação visual necessários para essa mudança. 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 têm como base a forma e a cor, que podem ser acessadas pelo .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 porque appearance: none não remove as bordas da caixa de seleção em todos os navegadores.

Miniatura

O elemento de miniatura já está no track à direita, mas precisa de estilos de círculo:

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

As ferramentas de desenvolvimento mostram o pseudoelemento do ponteiro do círculo em destaque.

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 é verificada antes da transição dos estilos de destaque de movimento ou de 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 definimos transition: transform var(--thumb-transition-duration) ease condicionalmente anteriormente neste elemento, essas mudanças podem ser animadas quando alteradas:

/* 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 um protótipo de uma barra lateral deslizante usando transformações CSS que lidavam com idiomas da direita para a esquerda, invertendo 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 ótima ideia de usar um valor de propriedade personalizado para inverter as porcentagens, permitindo o gerenciamento de um único local da nossa própria lógica 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 da chave vertical considera a posição do lado oposto exigido pelo 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 cursor é 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, já que precisa de temas claros e escuros com estados desativado e marcado. Escolhi estilos mínimos para esses estados para facilitar a manutenção das combinações de estilos.

Indeterminado

Um estado frequentemente esquecido é :indeterminate, em que uma caixa de seleção não está marcada nem desmarcada. Essa situação é divertida, convidativa e despretensiosa. 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 ao passar o cursor precisam oferecer suporte visual à interface conectada e também fornecer direção para a interface interativa. Essa chave destaca o círculo com um anel semitransparente quando o rótulo ou a entrada são passados. Essa animação ao passar o cursor direciona o cursor para o elemento interativo do polegar.

O efeito "destaque" é feito com box-shadow. Ao passar o cursor sobre uma entrada não desativada, aumente o tamanho de --highlight-size. Se o usuário concordar com o movimento, fazemos a transição da box-shadow e a vemos crescer. Se o usuário não concordar com o movimento, o destaque será exibido 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 poderá 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, um personalizado, o que o torna um ótimo candidato para os benefícios do 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 de que, quando um gesto do ponteiro começar dentro dessa faixa de alternância, processe gestos verticais e não faça 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. O JavaScript pode 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 de cache que mantêm o script eficiente. A leitura da direita para a esquerda é processada com a mesma propriedade personalizada que o CSS é --isLTR e pode usá-la para inverter a lógica e continuar oferecendo suporte à RTL. O event.offsetX também é valioso, porque contém um valor delta útil para posicionar o polegar.

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 longe do interruptor 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 e que a interface seja inteligente o suficiente para isso. 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
}

Outros pensamentos

O gesto de arrastar incorreu em uma dívida de código devido à estrutura HTML inicial escolhida, principalmente ao envolver a 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 considera o rótulo que recebe esse clique posterior, já que desmarcaria ou marcaria a interação que um usuário realizou.

Se eu fizesse isso de novo, poderia considerar ajustar o DOM com JavaScript durante o upgrade da UX, de forma a criar um elemento que manipule os cliques de rótulos sem entrar em conflito 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.