O que Padrões de Acessibilidade para Frontend faz?
Padrões práticos de acessibilidade para React e Next.js. Aborda os problemas mais comuns sinalizados em revisão de código: rótulos de formulário ausentes, uso incorreto de ARIA, elementos interativos não semânticos e navegação por teclado quebrada.
Quando Ativar
- Construindo ou revisando componentes de formulário (
<input>,<select>,<textarea>) - Criando elementos interativos (modais, dropdowns, tooltips, abas)
- Usando
<div>ou<span>comonClick - Adicionando atributos
aria-*a qualquer elemento - Implementando navegação por teclado ou gerenciamento de foco
- Recebendo feedback de acessibilidade de ferramentas de revisão de código (CodeRabbit, ESLint a11y)
- Construindo componentes que devem suportar leitores de tela
Acessibilidade de Formulários
A falta do par htmlFor / id e mensagens de erro desconectadas são os problemas mais comuns sinalizados em revisão de código.
Conexão do Rótulo
// RUIM: o rótulo não tem conexão com o input — leitores de tela não conseguem associá-los
<label>Email</label>
<input type="email" />
// BOM: htmlFor corresponde ao id do input
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Campos Obrigatórios
// RUIM: asterisco apenas visual não transmite nada para leitores de tela
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// BOM: required ativa validação nativa do navegador; aria-required sinaliza para leitores de tela
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
Mensagens de Erro
// RUIM: texto de erro existe visualmente, mas não está vinculado ao input
<input id="email" type="email" />
<span className="error">Endereço de e-mail inválido</span>
// BOM: aria-describedby conecta o input à sua mensagem de erro
// aria-invalid sinaliza o estado inválido para leitores de tela
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
Formulário Acessível Completo
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = 'Email é obrigatório';
if (!password) newErrors.password = 'Senha é obrigatória';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Senha <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Entrar</button>
</form>
);
}
HTML Semântico
Use o elemento que corresponde à intenção. Leitores de tela e usuários de teclado dependem de semântica nativa.
// RUIM: div não tem papel, nem suporte a teclado, nem nome acessível
<div onClick={handleClick}>Enviar</div>
// BOM: button é focalizável, ativa com Enter/Espaço, anuncia como "botão"
<button type="button" onClick={handleClick}>Enviar</button>
// RUIM: navegação não semântica
<div onClick={() => navigate('/home')}>Início</div>
// BOM: anchor suporta clique direito, clique do meio e navegação por teclado
<a href="/home">Início</a>
// RUIM: hierarquia de cabeçalhos pulada (h1 para h4)
<h1>Painel</h1>
<h4>Atividade Recente</h4>
// BOM: níveis de cabeçalho sequenciais
<h1>Painel</h1>
<h2>Atividade Recente</h2>
Atributos ARIA
Use ARIA apenas quando a semântica HTML nativa for insuficiente. ARIA errado é pior que nenhum ARIA.
aria-label vs aria-labelledby
// aria-label: rótulo de string inline — use quando não houver texto de rótulo visível
<button aria-label="Fechar modal">
<XIcon />
</button>
// aria-labelledby: referencia o texto de outro elemento — use quando houver um rótulo visível
<section aria-labelledby="section-title">
<h2 id="section-title">Pedidos Recentes</h2>
{/* conteúdo */}
</section>
aria-describedby
// Fornece descrição suplementar além do rótulo
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Excluir conta
</button>
<p id="delete-warning">Esta ação não pode ser desfeita.</p>
aria-live para Conteúdo Dinâmico
// Use aria-live para anunciar conteúdo que é atualizado sem recarregar a página
// polite: espera o usuário terminar a ação atual antes de anunciar
// assertive: interrompe imediatamente — use apenas para erros urgentes
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded e aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
Navegação por Teclado
Cada elemento interativo deve ser alcançável e operável apenas pelo teclado.
Dropdown Personalizado
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
Gerenciamento de Foco
O foco deve se mover logicamente quando o estado da interface muda — especialmente para modais e transições de rota.
Restauração de Foco em Modal
Este exemplo cobre o foco inicial e a restauração. Para um foco total (Tab/Shift+Tab ciclando dentro do modal), use uma biblioteca como
focus-trap-reactque lida com casos de borda como conteúdo dinâmico e portais aninhados.
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Salva o elemento atualmente focado e move o foco para o modal
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// Restaura o foco para o elemento que abriu o modal
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Fechar</button>
</div>
);
}
Imagens e Ícones
// RUIM: ícone decorativo anunciado como imagem não rotulada
<img src="/icon.svg" />
// BOM: imagem decorativa oculta de leitores de tela
<img src="/decoration.png" alt="" aria-hidden="true" />
// BOM: imagem significativa com texto alternativo descritivo
<img src="/chart.png" alt="A receita mensal aumentou 23% de janeiro a março" />
// BOM: botão de ícone com rótulo acessível
<button aria-label="Excluir item">
<TrashIcon aria-hidden="true" />
</button>
Movimento Reduzido
Respeite usuários que solicitaram movimento reduzido nas configurações do sistema operacional.
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Uso
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
Anti-Padrões
// RUIM: onClick em elemento não interativo sem suporte a teclado
<div onClick={handleClick}>Clique aqui</div>
// RUIM: aria-label em uma div que não tem papel
<div aria-label="Navegação">...</div>
// RUIM: placeholder usado como substituto de rótulo
<input placeholder="Digite seu email" />
// RUIM: tabIndex positivo cria ordem de tabulação imprevisível
<button tabIndex={3}>Enviar</button>
// RUIM: aria-hidden em elemento focalizável — usuários de teclado ficam presos
<button aria-hidden="true">Abrir</button>
// RUIM: role="button" em div sem manipulador de teclado
<div role="button" onClick={handleClick}>Enviar</div>
// Faltando: tabIndex={0}, onKeyDown para Enter/Espaço
Lista de Verificação
Antes de enviar qualquer componente interativo para revisão:
- Cada
<input>,<select>e<textarea>tem um<label>conectado viahtmlFor/id - Mensagens de erro estão vinculadas com
aria-describedbye marcadas comrole="alert" - Nenhum
onClickem<div>ou<span>semrole,tabIndexeonKeyDown - Botões apenas com ícone têm
aria-label - Imagens decorativas usam
alt=""earia-hidden="true" - Modais restauram o foco ao fechar (para foco total com Tab/Shift+Tab, use uma biblioteca como
focus-trap-react) - Atualizações de conteúdo dinâmico usam
aria-live -
prefers-reduced-motioné respeitado para animações
Habilidades Relacionadas
frontend-patterns— padrões gerais de componentes e estado Reactdesign-system— consistência de tokens de design e componentesmotion-foundationsemotion-patterns: padrões de animação com considerações de acessibilidade

