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-ringborder-brand + ring-[3px] ring-brand-mutedWCAG AA

O anel de foco

Borda da marca + halo opaco de 3px em brand-muted, colado, com o contorno nativo desligado. Uma receita só, a mesma do Card: campo, botão e cartão acendem igual. 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:border-brand focus-visible:ring-[3px] focus-visible:ring-brand-muted;
}

/* Mesmo anel, no roxo do agente: território de IA foca em roxo. */
@utility focus-ring-ia {
  @apply outline-none focus-visible:border-ia focus-visible:ring-[3px] focus-visible:ring-ia-muted;
}
dê Tab por aqui: os três são os componentes reais, com o anel real
EstadoReceitaOnde
Foco padrãofocus-visible:border-brand focus-visible:ring-[3px] focus-visible:ring-brand-mutedTodo campo e todo botão.
Foco de IAfocus-ring-ia · border-ia + ring-ia-mutedO 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-negative-softVem do atributo, não de classe condicional — quem marca é o Field. Mesma forma do foco, tinta vermelha opaca.
Tinta do lugarborder-warning + ring-warning-soft · border-destructive + ring-negative-softA FORMA nunca muda (borda 1px + halo opaco 3px, colado). A tinta segue o território: marca, IA, alerta, destrutivo.
Card com controle em focofocus-within:border-brand focus-within:ring-[3px] focus-within:ring-brand-mutedA MESMA receita, em focus-within, no card de form sempre aberto. Card de lista com edição (CrudCard) fica calmo: acende o tapete da linha.
Slot de OTP ativodata-[active=true]:border-brand data-[active=true]:ring-[3px] data-[active=true]:ring-brand-mutedO 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.

NotaA busca do paciente na folha de marcar não acende verde

O campo de busca da folha grande da Agenda (decisão do founder) mostra o foco escurecendo a borda da pílula para focus-within:border-fg-subtle, sem halo da marca. É a primeira exceção, e o degrau passa dos 3:1 nos cinco temas.

NotaNa ilha escura o anel é accent-dark

Dentro da barra de seleção (bg-ink-950 nos cinco temas) o halo da marca some no preto. Os botões dela usam focus-visible:ring-2 focus-visible:ring-accent-dark, o verde feito pra fundo escuro. É a segunda exceção.

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 app e do Atendimento⌘KAbre a janela de busca no alto da tela, com o foco no campo; setas andam, Enter abre, Esc fecha.
Histórico do Assistente⌘KA mesma janela, com as conversas da pessoa; o ⌘K de novo fecha.
Busca deste guia⌘KFoca o campo do rail; 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.