Pular para o conteúdo
Foco & acessibilidade

Fundações

Foco & acessibilidade

Um anel, uma medida, em todo alvo clicável — botão, campo, pílula de filtro, aba, card clicável feito à mão. Acessibilidade aqui não é uma passada no fim: metade das regras desta página existe porque alguém não conseguiu terminar uma tarefa.
@utility focus-ringring-[3px] ring-ring/50WCAG AA

O anel de foco

3px da cor --ring a 50%, com o contorno nativo desligado. Os dois SEMPRE juntos: anel sem outline-none desenha dois contornos.
globals.css — o utility que evita a cópia classe a classe
@utility focus-ring {
  @apply outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50;
}

/* Mesmo anel, no roxo do agente: território de IA foca em roxo. */
@utility focus-ring-ia {
  @apply outline-none focus-visible:ring-[3px] focus-visible:ring-(--ring-ia)/50;
}
dê Tab por aqui: os três são os componentes reais, com o anel real
EstadoReceitaOnde
Foco padrãofocus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50Todo campo e todo botão.
Foco de IAfocus-ring-ia · ring-(--ring-ia)/50O composer do agente e o que é território de IA. É o ÚNICO foco roxo do app.
Campo inválidoaria-invalid:border-destructive aria-invalid:ring-destructive/20Vem do atributo, não de classe condicional — quem marca é o Field.
Card com controle em focofocus-within:border-brand focus-within:ring-[3px] focus-within:ring-brand-mutedO card acende enquanto se edita dentro dele.
Slot de OTP ativodata-[active=true]:border-ring data-[active=true]:ring-[3px]O foco real está num input invisível; o slot desenha o estado.

Sim

Um alvo novo herda a medida do canon com uma classe. Mudar o anel do app vira uma linha em globals.css.

Não

Desligar o contorno nativo sem pôr o anel no lugar deixa quem navega por teclado sem saber onde está. É a falha de acessibilidade mais comum de um design system.

Notafocus-visible, não focus

O anel usa focus-visible: quem clica com o mouse não vê anel nenhum, quem chega de teclado vê. A exceção é o foco em camadas, que dispara em :focus puro — ali o gatilho precisa pegar o foco entregue por código e o toque no celular, que nunca marcam focus-visible.

Alvo de toque

Em aparelho de dedo cada controle sobe um degrau, sozinho. Ninguém compensa isso com altura fixa numa tela específica.
ui/button.tsx — pointer-coarse é a media query do dedo
default: "h-10 … pointer-coarse:h-11"   /* CTA 40 → 44px */
sm:      "h-8  … pointer-coarse:h-10"   /* toolbar 32 → 40px */
xs:      "h-6  … pointer-coarse:h-8"    /* chip 24 → 32px */
icon:    "size-10 pointer-coarse:size-11"

// Input e SelectNative: h-9 → pointer-coarse:h-10
// Checkbox:            size-4 → pointer-coarse:size-5
RegraDetalhe
Mínimo confortável44px no dedo pro alvo principal da tela; 40px pros controles de apoio.
Alvo pequeno pede áreaUm dot ou ícone de 14px dentro de um botão de 32px está certo: o alvo é o botão, não o desenho.
Nunca dois alvos coladosCluster de ações usa gap-1.5 no mínimo; “Editar” e “Excluir” encostados viram exclusão acidental.
Ação principal ao alcance do polegarNo celular os botões de diálogo empilham em flex-col-reverse: o confirmar fica embaixo.

Teclado

Toda tela precisa ser atravessável sem mouse. As regras abaixo cobrem os casos onde o comportamento padrão do navegador não basta.
PadrãoComportamentoNota
Abas (tablist)role=tablist/tab + aria-selected + ← → trocam de aba, com roving tabIndex (só a ativa é tabulável)aria-pressed numa aba é o papel errado — é crachá de toggle.
Radiogroup (temas, este seletor)role=radiogroup/radio + aria-checked + setasMesmo desenho das abas: uma parada de Tab pro grupo inteiro.
DiálogoEsc fecha, foco preso dentro, foco volta pro gatilho ao sairO Radix cuida — por isso o Dialog não é reimplementado à mão.
Tarja de confirmaçãoAbre com o foco no Cancelar; Esc cancela; cancelar devolve o foco pro gatilhoSem devolver o foco, o próximo Tab recomeça do topo da página.
Rail e sidebararia-current=page no item ativoÉ o que o leitor de tela usa pra dizer “você está aqui”.
Sidebar recolher⌘BA preferência mora em cookie: o servidor precisa da largura no primeiro HTML.
Busca do inbox / deste guia⌘KFoca o campo; não abre modal por cima do que se estava lendo.

Armadilha já pagaFoco perdido é tarefa perdida

Quando um elemento em foco SAI do DOM (a tarja fecha, a linha vira ficha, o diálogo desmonta), o foco cai no <body> e o próximo Tab recomeça do início da página. Todo componente que remove o próprio gatilho precisa devolver o foco — e só quando foi a PESSOA que fechou, nunca na primeira pintura.

Leitor de tela

As convenções que o produto já tem prontas. Nenhuma tela nova precisa inventá-las.

Erro de campo: a convenção {id}-error

Sem isso o leitor anuncia “inválido” e não diz o motivo.
ui/field.tsx — o fio é derivado, nunca digitado 3× por campo
aria-invalid={Boolean(messages)}
aria-describedby={[erro && `${id}-error`, hint && `${id}-hint`].filter(Boolean).join(" ")}

<FieldError id={`${id}-error`} role="alert" />   // fala mesmo com o foco no botão
<p id={`${id}-hint`} class="text-xs text-fg-subtle">…</p>

A dica entra no aria-describedby junto com o erro, e a ordem importa: erro primeiro, porque é o que precisa ser ouvido antes. Enquanto só o erro entrava, quem tabulava direto pro campo ouvia o rótulo e não ouvia a instrução — justamente as frases que explicam por que o campo existe.

SituaçãoMarcação
Erro geral do formuláriorole="alert" no FormAlert — e ele rola até ficar visível quando surge
Carregando uma regiãorole="status" aria-busy="true" aria-label="Carregando"
Estado que muda sozinho (salvo, salvando)aria-live="polite"
DiálogoDialogTitle é obrigatório — é o nome acessível do modal
Botão só de íconearia-label com o verbo e o objeto: “Excluir Marina Alvez”
Ícone decorativoaria-hidden (o Lucide já põe)
Imagem de avataralt="" — o nome já está escrito ao lado
Cartão de resumo que filtraaria-pressed no botão de filtro (aí sim é toggle)
Tabelacabeçalho real em <th>, não uma <div> com aparência de cabeçalho

Contraste

A régua é WCAG AA: 4.5:1 pra texto normal, 3:1 pra texto grande (≥18.66px bold ou ≥24px) e pra elemento de interface. Os números abaixo são medidos ao vivo, no tema que você escolheu.
Texto principal:1< 4.5
Texto secundário:1< 4.5
Caption e placeholder:1< 4.5
Texto sobre o fundo da página:1< 4.5
Tinta sobre o CTA:1< 4.5
Tinta sobre o botão de IA:1< 4.5
Item ativo da nav:1< 4.5
Selo Compareceu:1< 4.5
RegraDetalhe
Texto semântico usa o -strongO tom base sobre o -soft reprova AA em texto pequeno. A regra vale nos cinco temas, mesmo com a direção invertendo no escuro.
ink-400 está fora do mínimoÉ dot e ícone decorativo; o WCAG não exige contraste de elemento puramente decorativo. Se ele passar a carregar significado sozinho, muda de token.
Cor nunca é o único sinalTodo status tem rótulo escrito ao lado do dot, e o compromisso na agenda tem FORMA (listrado, vazado, ✓) além da cor — pintar o cartão não pode apagar “quem confirmou”.
O teste mede, a revisão não precisa lembrarcontraste.test.ts parseia globals.css e valida os pares dos cinco temas. Par novo reprovado = teste vermelho.

NotaO tema Ardósia tem um fg-subtle mais claro

Com o fundo já um degrau acima do preto, o cinza de legenda passava raspando dos 4.5 sobre a aba ativa. O teste pegou, e o token daquele tema subiu — é o tipo de ajuste que só existe porque a medição é automática.

O resto do checklist

Coisas que não cabem em nenhuma seção acima e mesmo assim reprovam uma tela.

Movimento reduzido

Toda animação tem par com motion-reduce. As utilities pulso e skeleton já carregam o par embutido.

Zoom até 200%

Layout em rem e grades que quebram; nada de altura fixa em px pra caixa de texto.

Autofill do Safari

O amarelo do preenchimento automático é coberto por box-shadow interno e o texto acompanha — num tema escuro ele acenderia no meio do formulário.

color-scheme por tema

Select, campo de data e barra de rolagem nativos seguem a UI, não o sistema operacional.

Idioma declarado

lang="pt-BR" no <html>: é o que faz o leitor de tela pronunciar “agendamento” em português.

Sem texto em imagem

A marca é SVG com nome acessível; qualquer outro texto é texto de verdade.