Acessibilidade na Prática (A11y): Desenvolvendo para Todos
Muito além do atributo 'alt'. Aprenda ARIA, focus trap, regiões live, testes com axe-core e as diretrizes WCAG para construir interfaces React verdadeiramente inclusivas.
Acessibilidade na Web (A11y — A, 11 letras, Y) não é um checklist de última hora. É uma disciplina de engenharia que, quando ignorada, exclui cerca de 1,3 bilhão de pessoas no mundo que vivem com alguma deficiência. No Brasil, segundo o IBGE, isso representa mais de 18 milhões de pessoas com deficiência visual e 10 milhões com deficiência auditiva.
O problema é que a maior parte dos tutoriais de acessibilidade para React apresenta apenas o básico — alt em imagens e usar <button> ao invés de <div>. Isso é necessário, mas absolutamente insuficiente para uma aplicação de produção. Neste artigo vamos a fundo.
A Base: HTML Semântico não é opcional
Antes de qualquer atributo ARIA, a primeira regra de ouro é: use o elemento HTML correto. O HTML nativo já carrega semântica, comportamento de teclado e anúncio para leitores de tela de graça. Elementos como <button>, <a>, <input>, <nav>, <main>, <article> e <section> comunicam estrutura e intenção sem uma linha de JavaScript.
// ❌ ERRADO: div com onClick não tem semântica nenhuma.
// Não ganha foco com Tab, não ativa com Enter/Space,
// leitores de tela ignoram ou leem como "clicável" sem contexto.
<div onClick={handleSubmit} className="btn-primary">
Enviar Formulário
</div>
// ✅ CORRETO: button carrega foco nativo, ativa com Enter e Space,
// leitores de tela anunciam como "Enviar Formulário, botão".
<button type="submit" onClick={handleSubmit} className="btn-primary">
Enviar Formulário
</button>
// ✅ CORRETO: Link de navegação. Ativa com Enter, anunciado como "link".
// Use <a> para navegar, <button> para ação.
<a href="/sobre">Sobre nós</a>A primeira regra do ARIA (especificação W3C) é: não use ARIA se um elemento HTML nativo resolve. ARIA não adiciona comportamento — só muda o que os leitores de tela anunciam. Um <div role="button"> sem tabIndex e sem handler de teclado ainda não funciona com Tab ou Enter.
ARIA na Prática: Quando e Como Usar
ARIA (Accessible Rich Internet Applications) é necessário quando construímos componentes sem equivalente HTML — tabs, accordions, carrosséis, comboboxes customizados. Existem três categorias de atributos ARIA que você precisa dominar:
- `role` — define o que é o elemento:
role="dialog",role="tablist",role="combobox" - `aria-
de estado** — descrevem o estado atual:aria-expanded,aria-checked,aria-disabled,aria-selected` - `aria-
de propriedade** — descrevem relações:aria-labelledby,aria-describedby,aria-controls`
Veja um componente de Accordion acessível completo, com todos os atributos necessários:
import { useState } from 'react';
interface AccordionItem {
id: string;
title: string;
content: string;
}
interface AccordionProps {
items: Array<AccordionItem>;
}
export function Accordion({ items }: AccordionProps) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div>
{items.map((item) => {
const isOpen = openId === item.id;
const headingId = `accordion-heading-${item.id}`;
const panelId = `accordion-panel-${item.id}`;
return (
<div key={item.id}>
{/* h3 garante hierarquia de headings na página */}
<h3>
<button
id={headingId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpenId(isOpen ? null : item.id)}
className="accordion-trigger"
>
{item.title}
{/* Ícone puramente decorativo — aria-hidden para o leitor de tela ignorar */}
<span aria-hidden="true">{isOpen ? '▲' : '▼'}</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headingId}
hidden={!isOpen}
>
<p>{item.content}</p>
</div>
</div>
);
})}
</div>
);
}
// O que o leitor de tela anuncia ao focar no botão fechado:
// "Pergunta frequente 1, botão, recolhido"
// Ao abrir:
// "Pergunta frequente 1, botão, expandido"Focus Trap: Aprisionando o Foco em Modais
Este é um dos erros mais comuns em SPAs. Quando um Modal abre, o foco deve ficar preso dentro dele. Se o usuário de teclado pressionar Tab repetidamente, o foco não pode vazar para o conteúdo por baixo do overlay. Isso não é só uma boa prática — é um requisito do WCAG 2.1 (critério 2.1.2 — Sem Armadilha de Teclado, paradoxalmente).
Aqui está a implementação de um hook useFocusTrap do zero, sem dependências externas:
import { useEffect, useRef } from 'react';
// Seletores de todos os elementos focáveis por padrão
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
// Guarda o elemento que estava focado antes do modal abrir
const previouslyFocused = document.activeElement as HTMLElement;
// Foca o primeiro elemento focável do modal
const focusableElements = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
);
focusableElements[0]?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const currentFocusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
);
const first = currentFocusable[0];
const last = currentFocusable[currentFocusable.length - 1];
if (event.shiftKey) {
// Shift+Tab: voltando — se está no primeiro, vai para o último
if (document.activeElement === first) {
event.preventDefault();
last?.focus();
}
} else {
// Tab: avançando — se está no último, vai para o primeiro
if (document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Ao fechar o modal, devolve o foco para onde estava antes
previouslyFocused?.focus();
};
}, [isActive]);
return containerRef;
}import { useEffect } from 'react';
import { useFocusTrap } from './useFocusTrap';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useFocusTrap(isOpen);
// Fecha o modal com Escape
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
// Impede o scroll do body enquanto o modal está aberto
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
// Overlay — aria-hidden=false já que é o conteúdo ativo
<div
role="presentation"
className="modal-overlay"
onClick={onClose} // Clique fora fecha o modal
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()} // Evita fechar ao clicar dentro
>
<h2 id="modal-title">{title}</h2>
{children}
<button
type="button"
onClick={onClose}
aria-label="Fechar modal"
className="modal-close"
>
✕
</button>
</div>
</div>
);
}Se preferir não implementar o focus trap manualmente, a biblioteca `focus-trap-react` resolve isso de forma robusta e battle-tested. Para componentes como dropdowns e dialogs, o Radix UI já vem com tudo isso configurado: focus trap, Escape para fechar, foco de retorno e atributos ARIA corretos.
Regiões Live: Anúncios Dinâmicos para Leitores de Tela
Um problema clássico em SPAs: o usuário clica em "Salvar" e uma mensagem de sucesso aparece visualmente, mas o leitor de tela não anuncia nada porque nenhuma navegação ocorreu. Para resolver isso, usamos aria-live.
Uma live region é um elemento que, quando seu conteúdo muda, é lido automaticamente pelo leitor de tela independente de onde o foco está. Existem dois valores principais:
- `aria-live="polite"` — anuncia quando o usuário para de interagir. Ideal para mensagens de sucesso, resultados de busca, contadores.
- `aria-live="assertive"` — interrompe o que o leitor de tela está fazendo e anuncia imediatamente. Use apenas para erros críticos.
import { useEffect, useState } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface ToastProps {
message: string | null;
type?: ToastType;
}
export function Toast({ message, type = 'info' }: ToastProps) {
// A live region DEVE existir no DOM antes da mensagem ser inserida.
// Por isso renderizamos sempre, mas com conteúdo vazio.
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true" // Lê o conteúdo completo, não apenas a diferença
className={`toast toast--${type} ${message ? 'toast--visible' : ''}`}
>
{/* O leitor de tela anuncia quando message muda de null para uma string */}
{message}
</div>
);
}
// Uso:
// <Toast message={null} /> — no DOM mas vazio, leitor de tela não anuncia nada
// <Toast message="Perfil salvo com sucesso!" type="success" />
// → Leitor de tela anuncia: "Perfil salvo com sucesso!"
// <Toast message="Senha incorreta. Tente novamente." type="error" />
// → Leitor de tela interrompe e anuncia imediatamente.Contraste de Cores e WCAG 2.1
O Web Content Accessibility Guidelines (WCAG) define critérios mensuráveis de acessibilidade. O nível AA é o mínimo exigido na maioria das legislações (incluindo a LBI — Lei Brasileira de Inclusão). Dois critérios de contraste que todo desenvolvedor frontend precisa saber:
- Critério 1.4.3 (Contraste — Nível AA): Texto normal deve ter razão de contraste mínima de 4.5:1 contra o fundo.
- Critério 1.4.11 (Contraste de não-texto — Nível AA): Bordas de inputs, ícones e componentes de UI devem ter razão de 3:1.
Uma forma fácil de verificar isso durante o desenvolvimento é usar a propriedade CSS color-contrast() (CSS Level 5) ou ferramentas como o WebAIM Contrast Checker. Mas a abordagem mais eficaz é integrar a verificação direto no seu sistema de design:
:root {
/* Paleta base */
--color-brand-500: #2563eb; /* Azul principal */
/* ✅ Texto branco sobre brand-500: ratio 4.64:1 (passa AA) */
--color-text-on-brand: #ffffff;
/* ❌ Texto cinza claro sobre branco: ratio 2.3:1 (REPROVA) */
/* --color-text-muted: #aaaaaa; */
/* ✅ Substituto acessível: ratio 4.6:1 (passa AA) */
--color-text-muted: #767676;
/* Nunca confie apenas na cor para transmitir informação. */
/* Combine com ícone, texto, padrão ou outra diferença visual. */
/* (Critério WCAG 1.4.1 — Uso da Cor) */
}Testando Acessibilidade com axe-core
Testes manuais com leitores de tela (NVDA no Windows, VoiceOver no macOS/iOS, TalkBack no Android) são indispensáveis, mas lentos. O axe-core é um motor de análise estática de acessibilidade que detecta automaticamente ~57% dos problemas conhecidos diretamente nos seus testes de unidade e integração.
npm install --save-dev @axe-core/react jest-axeimport { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Modal } from './Modal';
// Adiciona o matcher do jest-axe
expect.extend(toHaveNoViolations);
describe('Modal', () => {
it('não deve ter violações de acessibilidade', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}} title="Confirmar exclusão">
<p>Tem certeza que deseja excluir este item?</p>
<button type="button">Cancelar</button>
<button type="button">Excluir</button>
</Modal>
);
// axe analisa o HTML renderizado e retorna violações encontradas
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('deve ter aria-modal e aria-labelledby corretamente', () => {
const { getByRole } = render(
<Modal isOpen={true} onClose={() => {}} title="Confirmar exclusão">
<p>Conteúdo do modal</p>
</Modal>
);
const dialog = getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
});
});Além do jest-axe, adicione a extensão axe DevTools no Chrome ou Firefox. Ela audita a página atual com um clique e lista violações com explicações e links para correção. É o Lighthouse mas especializado em acessibilidade.
Navegação por Teclado em Listas e Grids
Para componentes de lista tipo <select> customizado, menu de navegação ou tabela de dados interativa, o padrão recomendado pelo WAI-ARIA é o Roving tabindex (ou "roving focus"). A ideia é simples: apenas um elemento dentro do grupo tem tabIndex={0} (focável pelo Tab). Os outros têm tabIndex={-1} (focáveis por JavaScript, mas não pelo Tab). As setas do teclado movem o foco entre os itens.
import { useRef, useState } from 'react';
interface MenuItem {
id: string;
label: string;
href: string;
}
export function NavigationMenu({ items }: { items: Array<MenuItem> }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef<Array<(HTMLAnchorElement | null)>>([]);
function handleKeyDown(event: React.KeyboardEvent, index: number) {
let nextIndex = index;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
nextIndex = (index + 1) % items.length;
break;
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
nextIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
event.preventDefault();
nextIndex = 0;
break;
case 'End':
event.preventDefault();
nextIndex = items.length - 1;
break;
default:
return;
}
setFocusedIndex(nextIndex);
itemRefs.current[nextIndex]?.focus();
}
return (
<nav aria-label="Menu principal">
<ul role="menubar">
{items.map((item, index) => (
<li key={item.id} role="none">
<a
ref={(el) => { itemRefs.current[index] = el; }}
href={item.href}
role="menuitem"
// Roving tabindex: só o item focado é focável via Tab
tabIndex={index === focusedIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
);
}Reduzindo Movimento para Quem Precisa
Animações e transições podem causar desconforto ou crises para pessoas com distúrbios vestibulares (epilepsia, vertigem). O WCAG 2.1 Critério 2.3.3 (Nível AAA) recomenda respeitar a preferência do sistema operacional. No CSS e no JavaScript, isso é trivial:
/* Animação padrão */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
/* Desativa animações se o usuário preferir movimento reduzido */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
/* Para animações longas (como loading spinners), reduza ao mínimo */
.spinner {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}import { useState, useEffect } from 'react';
// Hook que detecta a preferência do usuário em tempo real
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(() =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
useEffect(() => {
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Uso com Framer Motion:
// const prefersReduced = useReducedMotion();
// <motion.div animate={{ y: prefersReduced ? 0 : -10 }} />Conclusão
Acessibilidade real vai muito além de alt e <button>. Envolve entender o contrato entre HTML semântico, ARIA, foco de teclado e leitores de tela. A boa notícia é que cada uma dessas práticas não apenas inclui mais usuários — ela melhora a experiência de todos. Um componente com foco bem gerenciado é mais robusto, um código com contraste adequado é mais legível, e uma live region bem posicionada deixa a UX mais clara para qualquer pessoa.
Implemente progressivamente: comece auditando sua aplicação com o axe DevTools no Chrome. Corrija as violações críticas primeiro, depois refine componente por componente. Com prática, escrever código acessível vira instinto, não esforço.