Uma visão geral básica de como criar um componente de alternância responsivo e acessível.
Neste post, quero compartilhar uma forma de criar componentes de chave. Teste a demonstração.
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 do JavaScript torna o botão
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.
<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 oninput
e 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 públicas e particulares que os filhos usam para calcular os
layouts.
.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
:
<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 recebe o estilo de uma faixa de alternância removendo o appearance: checkbox
normal e fornecendo o próprio tamanho:
.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
:
.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.
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 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 são principalmente sobre a forma e a cor, que são acessadas
do .gui-switch
pai pela
cascata.
.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%;
}
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 é possível 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 um menu lateral deslizante usando transformações CSS que processavam 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 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 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:
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%);
}}
}
}
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. É 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>
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 sobre 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 é 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 às custas 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 troca, um gesto horizontal precisa
ser processado pelo nosso script ou um gesto vertical precisa ser capturado para a variante de
troca 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 o arrasto, vários valores de números 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 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 botão 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
}
Outras considerações
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 é 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 pequeno 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 links para mim e vou adicionar à seção de remixes da comunidade abaixo.
Remixes da comunidade
- @KonstantinRouda com um elemento personalizado: demo e code.
- @jhvanderschee com um botão: Codepen.
Recursos
Encontre o código-fonte do .gui-switch
no GitHub.