Acessibilidade (a11y) — Guia Prático
WCAG 2.1 AA, roles semânticos, gerenciamento de foco e como testar. O que toda entrevista Senior cobra.
Definição
Acessibilidade web garante que pessoas com deficiências (visual, motora, cognitiva, auditiva) possam usar o produto. WCAG 2.1 AA é o padrão de mercado — 4 princípios: Perceptível, Operável, Compreensível, Robusto. No React: HTML semântico primeiro, ARIA só quando necessário, gerenciamento de foco em SPAs, e testes automatizados com axe-core para pegar 30-40% dos problemas.
Problema → Solução
Problema
Modal que abre mas o foco não vai para dentro dele. Formulário com inputs sem label. Botão que é na verdade uma div sem role nem teclado. Menu dropdown que não fecha com Escape. Carousel que não pode ser pausado.Solução
HTML semântico: button, nav, main, header, footer em vez de div. Labels em todos os inputs. Gerenciar foco em modais (focus trap). Suporte a teclado em componentes customizados. Skip links para navegação por teclado. Testar com axe-core e keyboard-only navigation.Dica de entrevista
Tip
Regra de ouro: se você pode fazer com HTML semântico, não use ARIA. ARIA bem feito melhora a a11y. ARIA mal feito piora. A primeira pergunta é sempre: posso usar o elemento HTML nativo? button, select, input type=checkbox — todos têm comportamento acessível built-in.Código
// ✅ HTML semântico — sem ARIA desnecessário
<nav aria-label="Navegação principal">
<ul>
<li><a href="/about">Sobre</a></li>
</ul>
</nav>
<main>
<form onSubmit={handleSubmit}>
{/* Label explícita — não use placeholder como label */}
<label htmlFor="email">Email</label>
<input id="email" type="email" aria-describedby="email-hint" />
<div id="email-hint">Usaremos apenas para contato</div>
</form>
</main>
// ✅ Modal com focus trap (Radix Dialog)
import * as Dialog from '@radix-ui/react-dialog'
<Dialog.Root>
<Dialog.Trigger>Abrir</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content aria-describedby="dialog-desc">
{/* Foco vai aqui ao abrir, fica preso aqui, volta ao Trigger ao fechar */}
<Dialog.Title>Confirmar ação</Dialog.Title>
<Dialog.Description id="dialog-desc">Esta ação é irreversível.</Dialog.Description>
<Dialog.Close>Cancelar</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
// ✅ Teste automatizado com axe
import { axe } from 'jest-axe'
test('sem violações de a11y', async () => {
const { container } = render(<LoginForm />)
expect(await axe(container)).toHaveNoViolations()
})Perguntas de entrevista
Q.Como gerenciar foco em um modal?▾
A.Ao abrir: mover foco para o primeiro elemento focável dentro do modal (ou para o elemento com autofocus). Trap de foco: Tab circula dentro do modal, não escapa. Ao fechar: retornar foco para o elemento que abriu o modal. Usar Radix UI, Headless UI ou @radix-ui/react-focus-scope que implementam isso corretamente.
Q.Diferença entre aria-label e aria-labelledby?▾
A.aria-label: texto direto para o elemento. Usar quando não há texto visível associado: `<button aria-label='Fechar modal'>✕</button>`. aria-labelledby: referencia o ID de outro elemento como label. `<dialog aria-labelledby='dialog-title'>`. Prefira aria-labelledby quando há texto visível — evita duplicação e mantém sync.
Q.Como testar acessibilidade?▾
A.Automático: axe-core (@testing-library/jest-axe, @axe-core/playwright) pega ~30-40% dos problemas. Manual: navegar apenas com teclado (Tab, Shift+Tab, Enter, Space, Escape, setas). Screen reader: NVDA (Windows), VoiceOver (Mac/iOS), TalkBack (Android). Lighthouse a11y score > 90 como baseline.
Termos relacionados
75/122← →