Acessibilidade de Formulários
Atributos ARIA, gerenciamento de foco e anúncios de erro - torne cada formulário utilizável por todos.
Busque em todas as páginas da documentação
Atributos ARIA, gerenciamento de foco e anúncios de erro - torne cada formulário utilizável por todos.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida - pronto para copiar e colar.
// Padrão de campo de formulário acessível
function AccessibleField({
id,
label,
error,
required,
description,
children,
}: {
id: string;
label: string;
error?: string;
required?: boolean;
description?: string;
children: React.ReactNode;
}) {
const descIds = [
description ? `${id}-desc` : null,
error ? `${id}-error` : null,
].filter(Boolean).join(" ");
return (
<div>
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true" className="text-red-500"> *</span>}
{required && <span className="sr-only"> (obrigatório)</span>}
</label>
<div
// Clone os atributos aria nos filhos, ou envolva o input aqui
>
{children}
</div>
{description && (
<p id={`${id}-desc`} className="text-sm text-gray-500">{description}</p>
)}
{error && (
<p id={`${id}-error`} role="alert" className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
// Uso
<AccessibleField id="email" label="Email" error={errors.email} required>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby="email-desc email-error"
aria-required="true"
/>
</AccessibleField>Quando usar isso: Qualquer formulário. Acessibilidade não é opcional - é um requisito para qualquer aplicação de produção.
"use client";
import { useRef, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1, "O nome é obrigatório"),
email: z.string().email("Por favor, insira um email válido"),
subject: z.enum(["general", "support", "billing"], {
required_error: "Por favor, selecione um assunto",
}),
message: z.string().min(20, "A mensagem deve ter pelo menos 20 caracteres"),
});
type FormData = z.infer<typeof Schema>;
export function AccessibleContactForm() {
const errorSummaryRef = useRef<HTMLDivElement>(null);
const [announced, setAnnounced] = useState("");
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful, submitCount },
setFocus,
reset,
} = useForm<FormData>({
resolver: zodResolver(Schema),
mode: "onBlur",
});
// Foca no primeiro campo de erro após o envio falho
useEffect(() => {
if (submitCount === 0) return;
const errorKeys = Object.keys(errors) as (keyof FormData)[];
if (errorKeys.length > 0) {
setFocus(errorKeys[0]);
errorSummaryRef.current?.focus();
}
}, [errors, submitCount, setFocus]);
async function onSubmit(data: FormData) {
await new Promise((r) => setTimeout(r, 1000));
console.log("Submetido:", data);
setAnnounced("Sua mensagem foi enviada com sucesso.");
reset();
}
const errorEntries = Object.entries(errors).filter(([k]) => k !== "root");
return (
<div className="max-w-md">
{/* Região viva para anúncios de status */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announced}
</div>
{/* Resumo de erros - anunciado ao aparecer */}
{errorEntries.length > 0 && submitCount > 0 && (
<div
ref={errorSummaryRef}
tabIndex={-1}
role="alert"
aria-labelledby="error-heading"
className="mb-4 rounded border border-red-200 bg-red-50 p-4 outline-none focus:ring-2 focus:ring-red-500"
>
<h2 id="error-heading" className="font-medium text-red-800">
Há {errorEntries.length === 1 ? "1 erro" : `${errorEntries.length} erros`} em sua submissão
</h2>
<ul className="mt-2 list-inside list-disc text-sm text-red-700">
{errorEntries.map(([key, err]) => (
<li key={key}>
<a href={`#${key}`} className="underline hover:no-underline">
{err?.message}
</a>
</li>
))}
</ul>
</div>
)}
{isSubmitSuccessful && (
<div role="status" className="mb-4 rounded border border-green-200 bg-green-50 p-4 text-green-800">
Mensagem enviada com sucesso!
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} noValidate aria-label="Formulário de contato">
<fieldset disabled={isSubmitting} className="space-y-4">
<legend className="sr-only">Informações de contato</legend>
<div>
<label htmlFor="name" className="block text-sm font-medium">
Nome <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(obrigatório)</span>
</label>
<input
id="name"
{...register("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
aria-required="true"
className={`mt-1 w-full rounded border p-2 ${errors.name ? "border-red-500" : ""}`}
/>
{errors.name && (
<p id="name-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(obrigatório)</span>
</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
aria-required="true"
autoComplete="email"
className={`mt-1 w-full rounded border p-2 ${errors.email ? "border-red-500" : ""}`}
/>
{errors.email && (
<p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium">
Assunto <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(obrigatório)</span>
</label>
<select
id="subject"
{...register("subject")}
aria-invalid={!!errors.subject}
aria-required="true"
className={`mt-1 w-full rounded border p-2 ${errors.subject ? "border-red-500" : ""}`}
>
<option value="">-- Selecione --</option>
<option value="general">Consulta Geral</option>
<option value="support">Suporte</option>
<option value="billing">Faturamento</option>
</select>
{errors.subject && (
<p role="alert" className="mt-1 text-sm text-red-600">{errors.subject.message}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Mensagem <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(obrigatório)</span>
</label>
<textarea
id="message"
{...register("message")}
rows={4}
aria-invalid={!!errors.message}
aria-describedby="message-hint message-error"
aria-required="true"
className={`mt-1 w-full rounded border p-2 ${errors.message ? "border-red-500" : ""}`}
/>
<p id="message-hint" className="mt-1 text-xs text-gray-500">
Mínimo de 20 caracteres
</p>
{errors.message && (
<p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.message.message}
</p>
)}
</div>
<button
type="submit"
aria-disabled={isSubmitting}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Enviando..." : "Enviar Mensagem"}
</button>
</fieldset>
</form>
</div>
);
}O que isso demonstra:
aria-invalid, aria-describedby, aria-required em cada camporole="alert" para mensagens de erro (anúncio imediato)aria-live="polite" para anúncios de sucessosr-only para conteúdo visível apenas para leitores de telanoValidate para desabilitar a validação do navegador e usar mensagens personalizadasautoComplete para campos de emailaria-invalid="true" informa à tecnologia assistiva que o campo tem um erroaria-describedby vincula a entrada aos seus elementos de descrição e erro (IDs separados por espaço)role="alert" cria uma região viva ARIA que anuncia o conteúdo imediatamente quando ele aparecearia-live="polite" anuncia as alterações na próxima oportunidade disponível (não interruptivo)tabIndex={-1} torna um elemento focável via JS (.focus()), mas não na ordem de tabulaçãofieldset + disabled desabilita todas as entradas internas durante a submissão<a href="#fieldId"> permite que os usuários pulem para o campo problemáticoAnúncio automático de mudanças de estado do formulário:
function FormStatus({ isSubmitting, errorCount }: { isSubmitting: boolean; errorCount: number }) {
const message = isSubmitting
? "Enviando formulário..."
: errorCount > 0
? `O formulário tem ${errorCount} ${errorCount === 1 ? "erro" : "erros"}`
: "";
return (
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{message}
</div>
);
}Link "Pular para erros":
{errorEntries.length > 0 && (
<a href="#error-summary" className="sr-only focus:not-sr-only focus:absolute focus:p-2">
Pular para o resumo de erros
</a>
)}Contagem de caracteres com feedback em tempo real:
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current;
return (
<p
aria-live="polite"
aria-atomic="true"
className={`text-xs ${remaining < 20 ? "text-amber-600" : "text-gray-500"}`}
>
{remaining} caracteres restantes
</p>
);
}// Atributos aria com segurança de tipo
const ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;
// setFocus aceita nomes de campo tipados
const { setFocus } = useForm<FormData>();
setFocus("email"); // OK
setFocus("typo"); // Erro TSMuitos elementos role="alert" - Cada um anuncia imediatamente, sobrecarregando o usuário. Correção: Use um único resumo de erros com role="alert" e erros individuais sem ele, ou renderize os erros em sequência.
aria-describedby com IDs ausentes - Referenciar um ID inexistente é ignorado silenciosamente, mas confuso. Correção: Inclua apenas IDs que estão atualmente renderizados.
Indicação de erro apenas por cor - A cor sozinha falha no WCAG. Correção: Combine cor com mensagens de texto, ícones ou mudanças de borda.
Desabilitar o botão de envio - disabled remove o botão da ordem de tabulação. Correção: Use aria-disabled="true" com um manipulador de clique que impede a submissão, mantendo o botão focável.
Autofoco ao carregar a página - Mover o foco ao carregar desorienta usuários de leitores de tela. Correção: Mova o foco apenas em resposta a ações do usuário (como envio de formulário).
noValidate ausente - Pop-ups de validação do navegador são inacessíveis e inconsistentes entre navegadores. Correção: Adicione noValidate e lide com toda a validação em JS com ARIA adequado.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| shadcn Form | Ele lida com ARIA automaticamente via FormControl | Você precisa de comportamento ARIA personalizado |
| react-aria (Adobe) | Você quer uma biblioteca headless com suporte ARIA completo | Você já tem componentes acessíveis |
| Radix UI primitives | Você precisa de primitivas acessíveis (diálogos, selects, etc.) | Entradas nativas simples são suficientes |
| Validação HTML Nativa | Você quer validação básica sem JS | Você precisa de mensagens de erro personalizadas ou regras complexas |
aria-invalid="true" informa à tecnologia assistiva que o campo atualmente tem um erroaria-invalid={!!errors.fieldName}aria-describedby vincula uma entrada a um ou mais elementos que a descrevemaria-describedby="email-desc email-error"role="alert" anuncia o conteúdo imediatamente e interrompe a fala atualaria-live="polite" espera até que o leitor de tela termine o anúncio atualrole="alert" para erros; use aria-live="polite" para mensagens de sucesso/statustabIndex={-1} torna o elemento focável via JavaScript .focus(), mas o mantém fora da ordem normal de tabulação.focus() em um <div> não faria nadarole="alert" anuncia imediatamente quando aparecerole="alert" e omita a role nos erros de campos individuaisdisabled remove completamente o botão da ordem de tabulaçãoaria-disabled="true" com um manipulador de clique que impede a submissão em vez disso, mantendo o botão focávelnoValidate os desabilita para que você possa lidar com toda a validação em JavaScript com atributos ARIA adequados*) usa aria-hidden="true" para que os leitores de tela o ignorem<span className="sr-only">(obrigatório)</span> separado fornece o equivalente em textoconst ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;satisfies React.AriaAttributes para garantir que apenas props ARIA válidas sejam incluídasconst { setFocus } = useForm<FormData>();
setFocus("email"); // OK - "email" é uma chave de FormData
setFocus("typo"); // Erro TS - "typo" não é uma chavesetFocus aceita apenas nomes de campo do parâmetro genérico do formulário<fieldset disabled={isSubmitting}> desabilita todas as entradas filhas de uma vez durante a submissão<legend> para leitores de telasetFocus(errorKeys[0]) do RHF para mover o foco para o primeiro campo inválidoRevisado por Chris St. John·Última atualização: 16 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥