---
id: accessible-component-patterns
domain: web-craft
agents: [ux-design-expert, dev]
when: "ao construir um componente interativo que precisa ser acessível"
---

# Accessible Component Patterns — widgets que funcionam no teclado e no leitor de tela

## O problema

A maioria dos componentes interativos "acessíveis" é acessível só na aparência. Os tells de um
componente que vai falhar com um usuário de teclado ou leitor de tela:

1. **`<div onClick>` fingindo ser botão** — sem `role`, sem foco, sem resposta a Enter/Space. O leitor
   de tela anuncia "grupo" ou nada.
2. **ARIA decorativa** — `role="button"` colado num `<div>` mas **sem** o keyboard handler. Um `role`
   é uma promessa: você prometeu Enter/Space e não entregou. Pior que não ter ARIA.
3. **`aria-expanded` que nunca muda** — o atributo é setado uma vez no HTML e nunca atualizado pelo JS.
   O leitor de tela diz "expandido" com o painel fechado.
4. **Dropdown sem foco gerenciado** — abre o menu mas o foco continua no body; setas não navegam;
   Escape não fecha; foco não volta pro gatilho ao fechar.
5. **Modal que não prende o foco** — Tab vaza pra página atrás do overlay; Escape não fecha; ao fechar
   o foco se perde no topo da página.
6. **Tabs com todas as `tab` no tab order** — Tab passa por cada aba uma a uma em vez de setas
   navegarem entre abas e Tab pular pro painel (roving tabindex ausente).
7. **Foco invisível** — `outline: none` sem substituto. Ninguém de teclado sabe onde está.
8. **`aria-label` em tudo, redundante** — `<button aria-label="Fechar">Fechar</button>` faz o leitor
   ler "Fechar Fechar". Nome acessível duplicado.

A regra-mãe deste pack, direto do APG: **"No ARIA is better than bad ARIA."** E o corolário, a primeira
regra do ARIA: **se existe um elemento HTML nativo com a semântica e o comportamento que você precisa,
use ele em vez de reaproveitar um elemento e adicionar ARIA.** HTML nativo já traz foco, teclado,
semântica e estados — de graça e testado em todo AT.

## O conhecimento

### A primeira regra: HTML semântico primeiro (as 5 regras do ARIA)

Antes de escrever um único `role=`, passe por este filtro. Cada linha abaixo é uma troca concreta de
"ARIA caro e frágil" por "nativo barato e robusto":

| Quero… | NÃO faça (ARIA frágil) | Faça (nativo) |
|---|---|---|
| Botão | `<div role="button" tabindex="0">` + handlers de Enter/Space | `<button>` |
| Link | `<span role="link" tabindex="0">` | `<a href>` |
| Checkbox | `<div role="checkbox" aria-checked>` | `<input type="checkbox">` |
| Toggle on/off | `<div role="switch">` cru | `<input type="checkbox" role="switch">` (nativo + role) |
| Slider | `<div role="slider">` com todo o teclado na mão | `<input type="range">` |
| Select simples | combobox ARIA completa | `<select>` |
| Grupo de rádio | `role="radiogroup"` + `role="radio"` | `<fieldset>` + `<input type="radio">` |
| Progresso | `<div role="progressbar">` | `<progress>` |
| Seção colapsável simples | disclosure ARIA na mão | `<details>` / `<summary>` |
| Diálogo | `<div role="dialog">` + focus trap manual | `<dialog>` com `.showModal()` (trap + Escape de graça) |

Princípios literais do APG que governam tudo abaixo:

- **"A role is a promise."** Ao adicionar `role="X"`, você assume o contrato inteiro daquele role:
  o mapa de teclado, o gerenciamento de foco e os estados. ARIA não dá comportamento — só rótulo
  semântico. O comportamento é com você.
- **"ARIA can both cloak and enhance."** `role` sobrescreve a semântica nativa (perigoso: um
  `role="presentation"` num `<button>` apaga o botão). Use para *acrescentar* significado, não apagar.
- **"Testing assistive technology interoperability is essential before using code from this guide in
  production."** O APG não inclui workarounds para furos de suporte. Teste em NVDA/JAWS + VoiceOver
  antes de mandar pra produção.

---

### Disclosure (seção mostra/esconde) — o widget mais simples, comece por ele

O controle que mostra/esconde conteúdo tem `role="button"` (ou é um `<button>` nativo — prefira).

| Atributo | Onde | Valor |
|---|---|---|
| `aria-expanded` | no botão | `true` com conteúdo visível, `false` com conteúdo escondido — **atualize no JS a cada toggle** |
| `aria-controls` | no botão (opcional) | id do elemento que contém o conteúdo mostrado/escondido |

**Teclado:** `Enter` e `Space` ativam o controle e alternam a visibilidade. (Com `<button>` nativo,
isso vem de graça.)

> Para o caso simples (um cabeçalho que abre um painel), `<details>`/`<summary>` resolve sem nenhum JS
> nem ARIA. Só vá pra disclosure ARIA quando precisar de controle fino sobre animação/estado.

---

### Dialog (modal) — `<dialog>` nativo primeiro

**Prefira `<dialog>` + `dialog.showModal()`**: o navegador dá focus trap, Escape para fechar, o backdrop
`::backdrop` e o "inert" do resto da página. Só caia para ARIA na mão se precisar de suporte legado.

Se for ARIA na mão, o container tem `role="dialog"` (ou `role="alertdialog"` para mensagem curta e
crítica que exige resposta).

| Atributo | Valor / regra |
|---|---|
| `aria-modal` | `true` no container do dialog |
| `aria-labelledby` | aponta para o título visível do dialog |
| `aria-label` | use só se não houver título visível |
| `aria-describedby` | (opcional) aponta para o texto descritivo; **omita** se o corpo tiver estrutura semântica complexa |

**Teclado:**

| Tecla | Comportamento |
|---|---|
| `Tab` | move para o próximo tabbable; **dá a volta para o primeiro** se estiver no último |
| `Shift + Tab` | move para o tabbable anterior; dá a volta para o último se estiver no primeiro |
| `Escape` | fecha o dialog |

**Focus management (o que mais erram):**

- **Ao abrir:** o foco move para dentro do dialog — para o primeiro elemento focável, ou para um
  elemento estático com `tabindex="-1"` no início do conteúdo, ou (em operação destrutiva) para a
  ação menos destrutiva.
- **Focus trap:** "Tab e Shift+Tab não movem o foco para fora do dialog." Implemente o ciclo você
  mesmo se não usar `<dialog>` nativo.
- **Ao fechar:** o foco volta para o elemento que abriu o dialog — a menos que ele não exista mais ou
  o fluxo indique outro destino.
- Inclua um botão visível com `role="button"` que fecha (ícone X ou Cancelar).

---

### Tabs — roving tabindex obrigatório

| Role | Onde |
|---|---|
| `tablist` | container do conjunto de abas |
| `tab` | cada aba, dentro do `tablist` |
| `tabpanel` | cada painel de conteúdo |

| Atributo | Onde | Valor |
|---|---|---|
| `aria-selected` | em cada `tab` | `true` na aba ativa, `false` em todas as outras |
| `aria-controls` | em cada `tab` | id do `tabpanel` associado |
| `aria-labelledby` | no `tabpanel` | id da `tab` que o rotula |
| `aria-labelledby`/`aria-label` | no `tablist` | rótulo do conjunto |
| `aria-orientation` | no `tablist` | `vertical` se vertical; default é `horizontal` |
| `tabindex` | nas `tab` e no `tabpanel` | **roving**: aba ativa `0`, demais `-1`; painel sem focáveis recebe `tabindex="0"` |

**Teclado (orientação horizontal):**

| Tecla | Comportamento |
|---|---|
| `Tab` | entra/sai do tablist; foca a aba ativa, depois pula para o painel — **não** percorre aba por aba |
| `Seta direita` | próxima aba; da última volta para a primeira |
| `Seta esquerda` | aba anterior; da primeira volta para a última |
| `Seta baixo/cima` | substituem direita/esquerda quando `aria-orientation="vertical"` |
| `Home` / `End` | (opcional) primeira / última aba |
| `Space` / `Enter` | ativa a aba, **se** não houver ativação automática no foco |

**Ativação automática vs manual:** o APG recomenda **ativação automática no foco** (a aba ativa ao
receber foco) *contanto que* o painel apareça sem latência perceptível. Se trocar de painel for caro
(fetch), use ativação manual (Space/Enter) e marque a aba focada sem ativá-la.

**Roving tabindex** é o mecanismo de foco aqui: só um `tab` está no tab order (`tabindex="0"`); as
setas movem o foco e atualizam qual elemento tem o `0`.

---

### Menu / Menubar — roving tabindex OU aria-activedescendant

Use `role="menu"`/`role="menubar"` **só para menus de aplicação/comando** (ações). Para um menu de
navegação do site, isto é o widget errado — use `<nav>` + lista de links.

| Role | Uso |
|---|---|
| `menubar` | barra de menus horizontal (default `aria-orientation="horizontal"`) |
| `menu` | container de itens / submenu (default `vertical`) |
| `menuitem` | item de ação |
| `menuitemcheckbox` | item alternável (use `aria-checked`) |
| `menuitemradio` | item de escolha exclusiva (use `aria-checked`) |

| Atributo | Valor |
|---|---|
| `aria-haspopup` | `menu` ou `true` no item que tem submenu |
| `aria-expanded` | `false` com submenu escondido, `true` com submenu visível |
| `aria-checked` | `true` quando `menuitemcheckbox`/`menuitemradio` marcado |
| `aria-disabled` | `true` em item desabilitado (mantém focável e anunciado) |
| `aria-orientation` | `vertical` (default de `menu`) ou `horizontal` (default de `menubar`) |
| `tabindex` | roving: container `-1`; primeiro item do menubar `0`; demais `-1` |
| `aria-activedescendant` | abordagem alternativa: no container, aponta para o id do item ativo |

**Teclado:**

| Tecla | Comportamento |
|---|---|
| `Enter` | abre submenu (se houver) ou ativa o item e fecha o menu |
| `Space` | (opcional) alterna checkbox; marca radio; ativa item ou abre submenu |
| `Seta baixo` | no menubar abre submenu; no menu vai pro próximo item (volta opcional) |
| `Seta cima` | no menu vai pro item anterior (volta opcional) |
| `Seta direita` | no menubar próximo item; em submenu abre submenu irmão ou vai pro próximo item do menubar |
| `Seta esquerda` | no menubar item anterior; em submenu fecha o submenu ou volta pro menubar |
| `Home` / `End` | primeiro / último item |
| caractere | (opcional) typeahead: vai pro próximo item que começa com o caractere |
| `Escape` | fecha o menu e devolve o foco ao elemento que o abriu |

**Foco:** roving tabindex (DOM focus real no item) **ou** `aria-activedescendant` (foco fica no
container, atributo aponta pro item ativo). Escolha um e seja consistente.

---

### Combobox — o widget mais complexo, decida o `aria-autocomplete` certo

`role="combobox"` vai no **input**; o popup é `role="listbox"` (ou `grid`/`tree`/`dialog`).

| Atributo | Onde | Valor |
|---|---|---|
| `role="combobox"` | no input | identifica o widget |
| `aria-expanded` | no combobox | `false` com popup escondido, `true` com popup visível |
| `aria-controls` | no combobox | id do elemento popup |
| `aria-activedescendant` | no combobox | id da opção em foco dentro do popup durante navegação por teclado |
| `aria-autocomplete` | no combobox | `none`, `list` ou `both` conforme o comportamento |
| `aria-haspopup` | no combobox | `grid`/`tree`/`dialog` (implícito `listbox` no role combobox) |
| `aria-selected` | nas opções do popup | `true` na sugestão visualmente selecionada |
| `aria-labelledby`/`aria-label` | no combobox | nome acessível se não houver `<label>` |

**Teclado — no input (combobox):**

| Tecla | Comportamento |
|---|---|
| `Seta baixo` | abre o popup ou move o foco para dentro dele |
| `Seta cima` (opcional) | foca o último item focável do popup |
| `Escape` | fecha o popup; opcionalmente limpa o combobox |
| `Enter` | aceita a sugestão de autocomplete |
| caracteres imprimíveis | digitam no input editável |
| `Alt + Seta baixo` (opcional) | abre o popup sem mover o foco |
| `Alt + Seta cima` (opcional) | fecha o popup e devolve o foco ao combobox |

**Teclado — no popup listbox:**

| Tecla | Comportamento |
|---|---|
| `Enter` | aceita a opção em foco; fecha o popup |
| `Escape` | fecha o popup; foco volta ao combobox |
| `Seta baixo` / `Seta cima` | próxima / anterior opção |
| `Seta direita` / `Seta esquerda` | volta o foco ao combobox (se editável) e move o cursor |
| `Home` / `End` (opcional) | primeira / última opção |

**Foco:** o foco **fica no input** o tempo todo. A "posição" no popup é virtual via
`aria-activedescendant` (e a opção ativa ganha estilo de foco no CSS). Não mova DOM focus para as
opções. Esse é o padrão correto de combobox — diferente de uma listbox standalone.

---

### Listbox — single vs multi-select muda o teclado inteiro

| Role | Uso |
|---|---|
| `listbox` | container das opções |
| `option` | item selecionável |
| `group` | (opcional) agrupa opções, owned pela listbox |

| Atributo | Valor |
|---|---|
| `aria-multiselectable` | `true` para multi; `false` (default) para single |
| `aria-selected` | estado de seleção na `option` (`true`/`false`) |
| `aria-activedescendant` | foco virtual: id da opção em foco, sem mover DOM focus |
| `aria-labelledby`/`aria-label` | nome acessível da listbox |
| `aria-setsize` / `aria-posinset` | total e posição (em carregamento dinâmico) |
| `aria-orientation` | `horizontal` se horizontal; default `vertical` |
| `tabindex` | torna a listbox focável |

**Single-select:**

| Tecla | Comportamento |
|---|---|
| `Seta baixo` / `Seta cima` | move o foco; opcionalmente seleciona |
| `Home` / `End` | primeira / última opção (recomendado para 5+ itens) |
| typeahead | move para a próxima opção que começa com o(s) caractere(s) |

**Multi-select (modelo recomendado):**

| Tecla | Comportamento |
|---|---|
| `Space` | alterna a seleção da opção em foco |
| `Shift + Seta baixo/cima` | move o foco e alterna a seleção da próxima/anterior (opcional) |
| `Shift + Space` | seleciona contíguos do último selecionado até o foco (opcional) |
| `Ctrl + Shift + Home/End` | seleciona o foco e todos os anteriores/posteriores (opcional) |
| `Ctrl + A` | seleciona tudo; opcionalmente desmarca tudo se já estiver tudo selecionado (opcional) |

**Foco e seleção são coisas distintas.** O APG é explícito: "DOM focus (o active element) é
funcionalmente distinto do estado de seleção." Não confunda foco com selecionado, exceto no modelo
"selection follows focus" (geralmente single-select). Ao focar a listbox, o foco vai para a opção
selecionada (ou a primeira, se nenhuma).

---

### Slider — `<input type="range">` quase sempre vence

**Prefira `<input type="range">`**: dá role, teclado, valores e foco nativos. Só vá para
`role="slider"` na mão para sliders exóticos (range duplo, vertical sem suporte nativo, formato custom).

Se for ARIA na mão, o controle focável tem `role="slider"`.

| Atributo | Valor |
|---|---|
| `aria-valuenow` | valor atual (decimal) — **obrigatório, atualize no JS** |
| `aria-valuemin` | valor mínimo (decimal) |
| `aria-valuemax` | valor máximo (decimal) |
| `aria-valuetext` | (opcional) rótulo legível quando `aria-valuenow` sozinho não é claro (ex.: "R$ 1.200", "Médio") |
| `aria-orientation` | `vertical` se aplicável; default `horizontal` |
| `aria-labelledby`/`aria-label` | rótulo do slider |

**Teclado:**

| Tecla | Comportamento |
|---|---|
| `Seta direita` / `Seta cima` | aumenta o valor em um passo |
| `Seta esquerda` / `Seta baixo` | diminui o valor em um passo |
| `Home` | valor mínimo |
| `End` | valor máximo |
| `Page Up` (opcional) | aumenta em um passo maior |
| `Page Down` (opcional) | diminui em um passo maior |

---

### Switch — `aria-checked` binário, sem `mixed`

`role="switch"` (ou `<input type="checkbox" role="switch">`, que ainda dá o teclado nativo).

| Atributo | Valor |
|---|---|
| `aria-checked` | `true` quando ligado, `false` quando desligado — **sem `mixed`** (diferente de checkbox tri-state) |

**Teclado:** `Space` alterna o estado; `Enter` (opcional) também alterna.

> O APG mostra três implementações (div, button, input checkbox) e **não** obriga uma. Escolha o role
> que melhor casa design e semântica — mas lembre que o caminho nativo (`input`) já entrega foco e
> teclado.

---

### Foco visível — não negociável em nenhum widget

Todo o teclado acima é inútil se ninguém vê onde está o foco.

```css
/* Nunca remova o foco sem substituir. ERRADO: */
:focus { outline: none; }

/* CERTO: foco visível só para teclado, com a cor da marca */
:focus-visible {
  outline: 2px solid var(--brand-focus, #4f46e5);
  outline-offset: 2px;
  border-radius: 3px;
}
/* zere o outline default só no foco por ponteiro, mantendo :focus-visible */
:focus:not(:focus-visible) { outline: none; }
```

`:focus-visible` mostra o anel só quando o foco veio do teclado, não do clique — resolve a tensão
entre "designer odeia o anel no clique" e "usuário de teclado precisa do anel".

## Checklist

Antes de entregar um componente interativo, qualquer "não" é um bug:

- [ ] Existe um elemento HTML nativo que faria isso? Se sim, estou usando ele em vez de ARIA?
- [ ] Todo `role=` que adicionei vem com o **mapa de teclado completo** daquele role implementado?
- [ ] Todo estado dinâmico (`aria-expanded`, `aria-selected`, `aria-checked`, `aria-valuenow`) é
      **atualizado no JS** a cada mudança — não setado uma vez no HTML?
- [ ] `Escape` fecha o que abre (menu, dialog, popup do combobox)?
- [ ] Em dialog: foco entra ao abrir, fica preso (trap), e **volta ao gatilho** ao fechar?
- [ ] Em tabs/menu/listbox: uso **roving tabindex** ou `aria-activedescendant` — não tudo no tab order?
- [ ] Em combobox: o foco **permanece no input** e a navegação é via `aria-activedescendant`?
- [ ] O foco é **visível** (`:focus-visible` com contraste), sem `outline: none` solto?
- [ ] O nome acessível **não está duplicado** (texto visível + `aria-label` iguais)?
- [ ] Testei navegando só pelo teclado **e** com um leitor de tela real (NVDA/VoiceOver)?

## Tabela de decisão

| Preciso de… | Use isto | Foco | Estados-chave | Não esqueça |
|---|---|---|---|---|
| Mostrar/esconder seção simples | `<details>`/`<summary>` ou `<button aria-expanded>` | nativo | `aria-expanded` | atualizar `aria-expanded` no toggle |
| Janela modal | `<dialog>` + `.showModal()` | trap + retorno ao gatilho | `aria-modal`, `aria-labelledby` | foco entra ao abrir, volta ao fechar, `Escape` |
| Abas de conteúdo | `tablist`/`tab`/`tabpanel` | roving tabindex | `aria-selected`, `aria-controls` | setas navegam, Tab pula pro painel |
| Menu de comandos/ações | `menu`/`menuitem` | roving ou activedescendant | `aria-haspopup`, `aria-expanded`, `aria-checked` | `Escape` fecha e devolve foco |
| Menu de navegação do site | `<nav>` + `<a href>` | nativo | — | **não** use `role="menu"` aqui |
| Input com sugestões | `combobox` + `listbox` | foco fica no input | `aria-expanded`, `aria-activedescendant`, `aria-autocomplete` | DOM focus nunca sai do input |
| Seleção em lista (1 ou N) | `listbox`/`option` | activedescendant ou roving | `aria-selected`, `aria-multiselectable` | foco ≠ seleção em multi-select |
| Controle de valor numérico | `<input type="range">` | nativo | `value` | só vá pra `role="slider"` se nativo não der |
| Slider custom (range duplo etc.) | `role="slider"` | DOM focus no thumb | `aria-valuenow/min/max`, `aria-valuetext` | atualizar `aria-valuenow` no JS |
| Liga/desliga | `<input type="checkbox" role="switch">` | nativo | `aria-checked` (sem `mixed`) | `Space` alterna |
| Botão, link, checkbox, radio, select, progress | elemento HTML nativo | nativo | nativos | **primeira regra do ARIA: não use ARIA** |
