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.
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.
<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.
.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
:
<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:
.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
:
.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.
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.
.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%;
}
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:
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%);
}}
}
}
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>
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
- @KonstantinRouda por um elemento personalizado: demo e code.
- @jhvanderschee com um botão: Codepen;
Recursos
Encontre o .gui-switch
código-fonte no GitHub (em inglês).