Uma visão geral básica de como criar um componente de alternância responsivo e acessível.
Nesta postagem, quero compartilhar uma maneira de criar componentes de alternância. Teste a demonstração.
Se preferir vídeo, confira uma versão desta postagem no YouTube:
Visão geral
Um interruptor funciona de maneira semelhante a uma caixa de seleção, mas representa explicitamente estados booleanos ativados e desativados.
Esta demonstração usa <input type="checkbox" role="switch">
para a maioria das funcionalidades, o 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 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.
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 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 com preferência por movimento reduzido pode ser colocada em uma propriedade personalizada com o plug-in PostCSS com base neste rascunho de especificação em Media Queries 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Marcação
Optei por envolver meu elemento <input type="checkbox" role="switch">
com um
<label>
, agrupando seu relacionamento para evitar ambiguidade na associação de caixa de seleção e rótulo,
ao mesmo tempo em que ofereço ao usuário a capacidade de interagir 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 permitem uma pequena API de propriedade personalizada para facilitar as personalizações de componentes.
.gui-switch
O layout de nível superior para a chave é flexbox. A classe .gui-switch
contém
as propriedades personalizadas privadas e públicas 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 uma chave ou 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 é estilizada como 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 célula única de um por um para que um polegar reivindique.
Miniatura
O estilo appearance: none
também remove a marca de seleção visual fornecida pelo
navegador. Esse componente usa um pseudoelemento e a :checked
pseudoclasse na entrada para substituir esse indicador visual.
O controle deslizante é um filho pseudo-elemento anexado ao input[type="checkbox"]
e
se acumula em cima da faixa em vez de abaixo dela, reivindicando a área da 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 recursos de destaque ao toque e 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 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 eles podem ser um feedback valioso de interação visual. Não se esqueça de oferecer alternativas personalizadas se você remover essas opções.
Faixa
Os estilos desse elemento são principalmente sobre a forma e a cor, que ele acessa
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
é 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á à direita track
, 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 passar o 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 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 controle deslizante na faixa. Temos à disposição os tamanhos da faixa e do controle deslizante, que usaremos em cálculos para manter o controle deslizante corretamente deslocado e entre a faixa: 0%
e 100%
.
O elemento input
é proprietário da variável de posição --thumb-position
, e o pseudoelemento
de 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 de caixa de seleção. Como definimos condicionalmente transition: transform
var(--thumb-transition-duration) ease
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 desacoplada funcionou bem. O elemento de miniatura só se preocupa 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
.
No entanto, um elemento girado em 3D não muda a altura geral do componente, o que pode prejudicar o layout do bloco. Para isso, use as variáveis --track-size
e --track-padding
. Calcule o espaço mínimo necessário 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 de CSS, Elad Schecter, e eu criamos juntos um protótipo de 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 propriedades lógicas em CSS, e talvez nunca haja. Elad teve a ótima ideia de usar um valor de propriedade personalizada para inverter porcentagens e permitir o gerenciamento de um único local da nossa própria lógica personalizada para transformações lógicas. Usei essa mesma técnica neste switch e acho que deu muito certo:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Uma propriedade personalizada chamada --isLTR
inicialmente tem 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.
Coloque --isLTR
em ação usando-o em um calc()
dentro de uma transformação:
.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 exigida pelo layout da direita para a esquerda.
As transformações translateX
no pseudoelemento de miniatura 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 de CSS, ela oferece alguns princípios DRY para muitos casos de uso.
Estados
Usar o input[type="checkbox"]
integrado não estaria completo sem
processar os vários estados em que ele pode estar: :checked
, :disabled
,
:indeterminate
e :hover
. :focus
foi deixado intencionalmente 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 entrada "track"
é definido como a cor ativa, e a posição do controle deslizante é definida como "o
final".
.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 torna o
elemento imutável.A imutabilidade da interação é sem custo financeiro 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%);
}}
}
}
Esse estado é complicado porque precisa de temas escuros e claros com estados desativados e marcados. Escolhi estilos mínimos para esses estados para facilitar a manutenção das combinações de estilos.
Indeterminado
Um estado muitas vezes 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 intermediários sorrateiros.
É difícil definir uma caixa de seleção como indeterminada. Somente 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, para mim, é discreto e convidativo, achei adequado colocar a posição do botão de alternância 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 indicar o caminho para a interface interativa. Esse botão destaca o controle deslizante com um anel semitransparente quando o rótulo ou a entrada são passados com o cursor. Essa animação de passar o cursor fornece direção para o 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 não se importar com o movimento, vamos fazer a transição do box-shadow
e vê-lo crescer. Se ele não quiser movimento, 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, especialmente esse tipo com um círculo dentro de uma faixa. O iOS acertou isso com a alternância. É possível arrastar 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.
Miniaturas arrastáveis
O pseudoelemento de polegar recebe sua posição do .gui-switch > input
com escopo var(--thumb-position)
. 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 a ação de arrastar estava mais próxima de "desativado" ou "ativado" usando a propriedade personalizada
--thumb-position
. Essa é a espinha dorsal da solução: eventos de ponteiro
rastreando condicionalmente as posições do ponteiro para modificar propriedades personalizadas do CSS.
Como o componente já estava 100% funcional antes da exibição desse script, é preciso bastante trabalho para manter o comportamento atual, como clicar em um rótulo para ativar/desativar a entrada. Nosso JavaScript não deve adicionar recursos à custa dos recursos atuais.
touch-action
Arrastar é um gesto personalizado, o que o torna um ótimo candidato 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 nesse elemento. Assim, um script pode processar um gesto sem concorrência.
O CSS a seguir instrui o navegador a processar gestos verticais quando um gesto de ponteiro começa dentro dessa faixa de alternância e a não fazer nada com gestos horizontais:
.gui-switch > input {
touch-action: pan-y;
}
O resultado desejado é um gesto horizontal que não mova nem role 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
Durante a configuração e a ação de arrastar, vários valores numéricos calculados precisam ser extraídos
dos elementos. As seguintes funções JavaScript retornam valores de pixel calculados
com base em uma propriedade CSS. Ele é usado no script de configuração assim:
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 pseudo-elementos.
dragging
Esse é um momento essencial para a lógica de arrastar, e há algumas coisas importantes 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 junto 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. O processamento da direita para a esquerda é feito usando a mesma propriedade personalizada
que o CSS --isLTR
, e é possível usá-la 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 indicador.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Essa linha final de CSS define a propriedade personalizada usada pelo elemento de miniatura. Essa atribuição de valor seria transferida 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 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 um usuário tenha liberdade para arrastar sem precisão e que a interface seja inteligente o suficiente para levar isso em consideração. Não foi preciso muito para lidar com isso com essa troca, mas foi necessário considerar cuidadosamente 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 "checked" da entrada
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 indicador está dentro dos limites da faixa e retorna "true" se ele estiver na metade ou mais da 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 ideias
O gesto de arrastar gerou um pouco de dívida técnica devido à estrutura HTML inicial escolhida, principalmente envolvendo 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
, talvez você tenha 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 desmarca ou marca a interação realizada por um usuário.
Se eu fizesse isso de novo, talvez ajustasse o DOM com JavaScript durante o upgrade da UX para criar um elemento que lide com os cliques de rótulo e não entre em conflito com o comportamento integrado.
Esse tipo de JavaScript é o que menos gosto de escrever. Não quero gerenciar propagação condicional de eventos:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusão
Esse pequeno componente de troca 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, me envie um tweet com o link, e eu vou adicionar à seção de remixes da comunidade abaixo.
Remixes da comunidade
- @KonstantinRouda com um elemento personalizado: demonstração e código.
- @jhvanderschee com um botão: Codepen.
Recursos
Encontre o .gui-switch
código-fonte no
GitHub.