R
Interview Deckreact · next.js · senior/staff
0·0·122
Acessibilidade

Acessibilidade (a11y) — Guia Prático

WCAG 2.1 AA, roles semânticos, gerenciamento de foco e como testar. O que toda entrevista Senior cobra.

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
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.
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.
// ✅ 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()
})
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.
75/122← →