Interruptor só com CSS que funciona no teclado e no leitor de tela
Monte um interruptor liga/desliga a partir de um checkbox real, sem JavaScript, mantendo foco visível, rótulo clicável e estado anunciado pelo leitor de tela.
A tela de preferências ganhou interruptores bonitos, feitos só com CSS a partir de checkboxes. No teste com o teclado, o Tab passa por eles sem mostrar nada, ou pula direto para o botão Salvar. O motivo é uma linha: display: none no input. A resposta curta: esconda a aparência do checkbox, nunca o checkbox. O input continua na página, recebe o foco, tem um rótulo ligado por for e mostra um anel de foco visível no desenho do interruptor. Feito assim, o componente funciona com mouse, teclado, toque e leitor de tela sem uma linha de JavaScript.
Componente só com CSS é uma ótima escolha justamente porque herda o comportamento do controle nativo. O erro é jogar fora essa herança no momento de estilizar.
Por que o interruptor some do teclado
Um checkbox nativo traz de graça tudo o que dá trabalho fazer à mão: entra na ordem do Tab, alterna com a tecla Espaço, é enviado no formulário, aparece para o leitor de tela como "caixa de seleção, marcada" ou "não marcada" e fica clicável pelo rótulo. Tudo isso depende de o elemento existir na árvore de acessibilidade.
display: none e visibility: hidden removem o elemento dessa árvore e da ordem de foco. O rótulo ainda alterna o estado quando clicado com o mouse, e por isso o componente parece funcionar no teste rápido. Para o teclado ele deixou de existir. A outra forma de quebrar é mais sutil: o input fica, mas alguém escreve outline: none para tirar o "contorno feio" e não coloca nada no lugar. O foco está lá, só que ninguém vê onde.
O terceiro problema é o estado comunicado apenas por cor. Verde para ligado e cinza para desligado falha para quem não distingue essas cores e falha no modo de alto contraste do Windows, em que o navegador substitui as cores de fundo definidas pela página.
Como montar o interruptor a partir do checkbox
O exemplo é a preferência "Receber notificações por e-mail". A técnica usada aqui estiliza o próprio input com appearance: none, então o elemento que recebe o foco é o mesmo que você desenha. Não há input escondido nem pseudo-elemento no rótulo para sincronizar.
- Comece pelo HTML nativo: um
input type="checkbox"comid,namee umlabelcomforapontando para ele. - Anuncie como interruptor:
role="switch"é permitido em checkbox e faz leitores de tela compatíveis anunciarem "ligado" e "desligado". Sem ele, o anúncio de caixa marcada continua correto. - Remova só a aparência:
appearance: nonetira o desenho padrão e mantém foco, clique e envio no formulário. - Desenhe trilho e botão: o trilho é o próprio input e o botão é um pseudo-elemento que se desloca quando o estado é
:checked. - Mostre o foco:
:focus-visiblecom um contorno de pelo menos 2 px e cor com bom contraste contra o fundo. - Diferencie o estado além da cor: a posição do botão muda, e a borda continua visível nos dois estados.
O HTML
<div class="preferencia">
<input type="checkbox" id="notificacoes" name="notificacoes"
class="interruptor" role="switch" checked>
<label for="notificacoes">Receber notificações por e-mail</label>
</div>
O CSS
.interruptor {
appearance: none;
position: relative;
width: 2.75rem;
height: 1.5rem;
margin: 0 0.5rem 0 0;
border: 2px solid #555;
border-radius: 999px;
background: #fff;
cursor: pointer;
vertical-align: middle;
}
.interruptor::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: #555;
transition: transform 0.15s;
}
.interruptor:checked {
background: #0b6e4f;
border-color: #0b6e4f;
}
.interruptor:checked::before {
background: #fff;
transform: translateX(1.25rem);
}
.interruptor:focus-visible {
outline: 3px solid #1a5fb4;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.interruptor::before {
transition: none;
}
}
As medidas fecham: o trilho tem 2,75 rem por dentro da borda, o botão tem 1,25 rem e começa a 2 px da esquerda, e o deslocamento de 1,25 rem o leva até 2 px da direita. Se mudar uma medida, refaça essa conta, senão o botão encosta ou sobra espaço de um lado só. Pseudo-elemento em checkbox com appearance: none funciona nos navegadores atuais, mas vale conferir nos que o seu público usa.
Quando você precisa esconder o input
Alguns designs pedem o desenho no rótulo, por exemplo um interruptor grande com ícones. Nesse caso, o input sai da vista com a técnica de "visualmente escondido" (posição absoluta, 1 px de tamanho, clip-path ou clip, overflow: hidden), nunca com display: none. E o anel de foco vai para o desenho com o seletor de irmão: .interruptor:focus-visible + label::before. O guia sobre esconder o botão de escolher arquivo sem esconder o teclado usa a mesma técnica num input de arquivo.
Como testar em dois minutos
Não precisa de ferramenta paga. Faça quatro testes, nesta ordem, sempre com a mesma preferência de notificações:
- Aperte Tab a partir do topo da página. O anel de foco tem de aparecer no interruptor, não em algum lugar invisível.
- Com o foco nele, aperte Espaço. O estado muda e o botão se desloca.
- Clique no texto "Receber notificações por e-mail". O estado muda também.
- Ligue o leitor de tela do sistema (Narrador no Windows, VoiceOver no macOS) e chegue ao controle. Ele precisa ler o rótulo e o estado.
Repita o primeiro teste no tema escuro, se o site tiver um. Contorno azul-escuro some em fundo escuro, um problema detalhado no post sobre anel de foco que some no tema escuro.
Erros comuns
display: noneouvisibility: hiddenno input: o controle sai da ordem do Tab e do leitor de tela, e só o mouse consegue alternar.outline: nonesem substituto: o foco existe, mas quem navega pelo teclado não sabe onde está e aciona o controle errado.- Estado só pela cor: no alto contraste do Windows ou para quem não distingue verde de cinza, ligado e desligado ficam iguais.
- Div com clique em vez de checkbox: perde o envio no formulário, o Espaço, o anúncio de estado e o rótulo clicável, e você passa a reescrever tudo em JavaScript.
- Rótulo sem
forou comiderrado: o leitor de tela anuncia um controle sem nome e clicar no texto não faz nada. - Interruptor que aplica a mudança na hora sem dizer: se ligar já salva, mostre uma confirmação em texto. Se depende do botão Salvar, não sugira o contrário.
Checklist
- O controle é um
input type="checkbox"real, comnameeid. - Existe um
labelcomforigual aoiddo input. - Nenhuma regra aplica
display: noneouvisibility: hiddenno input. - O foco por teclado mostra um contorno visível, também no tema escuro.
- Espaço alterna o estado quando o controle está focado.
- Ligado e desligado se distinguem pela posição, não só pela cor.
- O formulário envia o valor do checkbox quando ele está marcado.
Abra a página de configurações do seu sistema, ponha o mouse de lado e tente desligar uma notificação usando só Tab e Espaço. Você consegue ver onde o foco está o tempo todo? Se não consegue, o CSS limpou demais.
Referência: http://tableless.com.br/elementos-de-interface-utilizando-apenas-css3/