Como criar um componente de switch

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

Nesta postagem, quero compartilhar ideias sobre como criar componentes de switch. Teste a demonstração.

Demonstração

Se preferir vídeo, aqui está uma versão do YouTube desta postagem:

Visão geral

Um switch funciona de maneira semelhante a uma caixa de seleção, mas representa explicitamente os estados booleanos de ativação e desativação.

Esta demonstração usa o <input type="checkbox" role="switch"> na maior parte da funcionalidade, o que tem a vantagem de não precisar que CSS ou JavaScript seja totalmente funcional e acessível. O carregamento de CSS é compatível com idiomas da direita para a esquerda, verticalidade, animação e muito mais. Carregar o JavaScript torna a chave arrastável e tangível.

Propriedades personalizadas

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

Monitorar

O comprimento (--track-size), o padding e as 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 da 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 do usuário de preferência de movimento reduzida pode ser colocada em uma propriedade personalizada com o plug-in PostCSS com base nesta especificação de rascunho em consultas de mídia 5:

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

Marcação

Escolhi unir o elemento <input type="checkbox" role="switch"> com um <label>, agrupando a relação para evitar ambiguidade na associação de caixas de seleção e rótulos, além de oferecer ao usuário a capacidade de interagir com o rótulo para alternar a entrada.

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

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

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

Layouts

As propriedades Flexbox, grid 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 permitem uma pequena API de propriedade personalizada para facilitar a personalização dos componentes.

.gui-switch

O layout de nível superior da chave é o flexbox. A classe .gui-switch contém as propriedades personalizadas privadas e públicas que os filhos usam para calcular os layouts.

O Flexbox DevTools sobrepõe um rótulo horizontal e uma chave, mostrando a distribuição
do layout do espaço.

.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 a flex-direction:

Flexbox DevTools sobrepondo um identificador vertical e um switch.

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

Monitorar

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

O Grid DevTools sobrepõe 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 trilha de grade de células uma por uma para um polegar para 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 círculo é um pseudoelemento filho anexado ao input[type="checkbox"] e empilha sobre a faixa em vez de abaixo, reivindicando a área de grade track:

DevTools mostrando a miniatura do pseudoelemento posicionada 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 chave versátil que se adapta a esquemas de cores, idiomas da direita para a esquerda e preferências de movimento.

Uma comparação lado a lado do tema claro e escuro para a chave e os
estados dela.

Estilos de interação por 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 de maneira negativa o feedback de interação visual e estilo necessário para essa troca. 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 é aconselhável remover esses estilos, já que podem ser valiosos feedbacks de interação visual. Forneça alternativas personalizadas se você as remover.

Monitorar

Os estilos desse elemento estão relacionados principalmente à forma e à cor, que ele acessa do .gui-switch pai pela cascata.

A chave vai variar de acordo com o tamanho e a cor das faixas.

.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 ampla variedade de opções de personalização é proveniente de quatro propriedades personalizadas. O 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 círculo já está na track direita, mas precisa de estilos de círculo:

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

DevTools mostrando destacando o pseudoelemento do círculo polegar.

Interação

Use propriedades personalizadas para se preparar para interações que vão mostrar destaques ao passar o cursor e mudanças na posição do polegar. A preferência do usuário também é verificada antes de fazer a 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 única para posicionar o círculo na faixa. À nossa disposição estão os tamanhos da faixa e do círculo que vamos usar em cálculos para manter o polegar corretamente deslocado e entre dentro da faixa: 0% e 100%.

O elemento input é proprietário da variável de posição --thumb-position, e o pseudoelemento em miniatura a 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 das pseudoclasses fornecidas nos elementos da caixa de seleção. Como definimos condicionalmente transition: transform var(--thumb-transition-duration) ease anteriormente nesse 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 separada funcionou bem. O elemento de círculo só se refere a 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.

No entanto, um elemento rotacionado 3D não muda a altura geral do componente, o que pode eliminar o layout de blocos. 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 da maneira esperada:

.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 prototipamos um menu lateral usando transformações CSS que lidam 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. A Elad teve a grande ideia de usar um valor de propriedade personalizada para inverter porcentagens, permitindo o gerenciamento de um único local da nossa própria lógica personalizada para transformações lógicas. Usei essa mesma técnica nessa mudança e acho que funcionou muito bem:

.gui-switch {
  --isLTR: 1;

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

Uma propriedade personalizada com o nome --isLTR inicialmente contém um valor de 1, o que significa que é true, já que nosso layout é da esquerda para a direita por padrão. Em seguida, usando a pseudoclasse :dir() (link em inglês) do CSS, o valor será definido como -1 quando o componente estiver em um layout da direita para a esquerda.

Coloque o --isLTR em ação usando-o em uma calc() dentro de uma transformação:

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

Agora, a rotação do interruptor vertical considera a posição do lado oposto exigida pelo layout da direita para a esquerda.

As transformações translateX no pseudoelemento básico 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 funcione para resolver todas as necessidades relacionadas a um conceito, como transformações lógicas CSS, ela oferece alguns princípios DRY para muitos casos de uso.

Estados

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

Captura de tela do anel de foco focado em um interruptor 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 "track" de entrada é definido como a cor ativa, e a posição do polegar é 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 tem uma aparência diferente, mas também deve tornar o elemento imutável.A imutabilidade da interação não está disponível no 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%);
    }}
  }
}

Chave de estilo escuro nos estados desativado, marcado e desmarcado.

Esse estado é complicado, porque precisa de temas claro e escuro com estados desativados e marcados. Escolhi estilos mínimos para esses estados a fim de facilitar a carga de manutenção das combinações de estilos.

Indeterminado

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

É complicado definir uma caixa de seleção como indeterminada, apenas JavaScript pode configurá-la:

<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 círculo da faixa no
meio, para indicar indeciso.

Como o estado, para mim, é despretensioso e convidativo, parece apropriado colocar a posição do botão do botão 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 de passar o cursor precisam oferecer suporte visual para a interface conectada e também direcionar para a interface interativa. Essa chave destaca o círculo com um anel semitransparente quando o usuário passa o cursor sobre o rótulo ou a entrada. Essa animação de passar o cursor fornece a direção do elemento de polegar interativo.

O efeito de 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, faremos a transição da box-shadow e ela vai crescer. Se o movimento não estiver satisfatório, 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 alternância pode parecer estranha na tentativa de emular uma interface física, principalmente esse tipo com um círculo dentro de uma faixa. O iOS acertou com a chave, é possível arrastá-los de um lado para o outro, 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.

Ícones arrastáveis

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 in-line na entrada para atualizar dinamicamente a posição do polegar, fazendo com que pareça seguir o gesto do ponteiro. Quando o ponteiro for solto, remova os estilos in-line e determina se a ação de arrastar estava mais perto de desativada ou ativada usando a propriedade personalizada --thumb-position. Essa é a espinha dorsal da solução. Eventos de ponteiro rastreiam condicionalmente as posições do ponteiro para modificar propriedades personalizadas de CSS.

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

touch-action

Arrastar é um gesto personalizado, 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 nesse elemento para que um script possa processar um gesto sem concorrência.

O CSS a seguir instrui o navegador que, quando um gesto de ponteiro for iniciado dentro dessa faixa de alternância, processe gestos verticais, não faça nada com os horizontais:

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

O resultado desejado é um gesto horizontal que não movimenta ou rola a página. Um ponteiro pode rolar verticalmente começando de dentro da entrada e rolar a página, mas os horizontais têm processamento personalizado.

Utilitários de estilo de valor de pixel

Na configuração e durante a ação de arrastar, vários valores numéricos calculados precisarão ser extraídos dos elementos. As funções JavaScript a seguir retornam valores de pixel calculados dada 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. Que legal que o JavaScript pode ler tantos valores de elementos, até mesmo de pseudoelementos.

dragging

Esse é um momento essencial para a lógica de arrastar e há alguns aspectos a serem observados 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 hero do script é state.activethumb, o pequeno círculo que esse script está posicionando com um ponteiro. O objeto switches é um Map() em que as chaves são as .gui-switch e os valores são limites e tamanhos armazenados em cache que mantêm o script eficiente. O modo da direita para a esquerda é processado usando a mesma propriedade personalizada que o CSS é --isLTR, podendo ser usado 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 círculo.

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

Essa linha final do CSS define a propriedade personalizada usada pelo elemento thumb. Essa atribuição de valor mudaria com o 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, é necessário registrar um evento de janela global:

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

  dragEnd(event)
})

Acho muito importante que o usuário tenha liberdade para arrastar com calma e que a interface seja inteligente o suficiente para dar conta disso. Não foi preciso muito para lidar com isso com essa opção, 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 verificada e remover todos os eventos de gesto. A caixa de seleção é modificada com state.activethumb.checked = determineChecked().

determineChecked()

Essa função, chamada por dragEnd, determina onde a corrente do círculo está dentro dos limites da faixa e retorna "true" se é igual ou acima da metade ao longo 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
}

Pensamentos extras

O gesto de arrastar gerou um pouco de débito de código devido à estrutura HTML inicial escolhida, principalmente unindo a entrada em um rótulo. O rótulo, por ser um elemento pai, receberia interações de clique após a entrada. No final do evento dragEnd, é possível que você tenha notado padRelease() como uma função que parece estranha.

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

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

Isso leva em consideração o rótulo que recebe esse clique posterior, já que ele 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, para criar um elemento que gerencie os cliques nos rótulos e não atrapalhe o comportamento integrado.

Esse tipo de JavaScript é o meu menos favorito para escrever, não quero gerenciar a propagação de eventos condicionais:

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

Conclusão

Esse pequeno componente de switch acabou sendo o mais trabalhoso de todos os desafios de GUI até agora! 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

Recursos

Encontre o .gui-switch código-fonte no GitHub (em inglês).