Formulários são o ponto mais crítico de acessibilidade em qualquer aplicação web. É onde o usuário realiza ações — comprar, cadastrar, enviar. Uma falha aqui não é um inconveniente: é exclusão direta.
Segundo o WebAIM Million 2024, labels ausentes ou incorretas aparecem em 45,4% das páginas auditadas — a segunda falha mais comum em toda a web, perdendo apenas para contraste insuficiente.
O que o WCAG exige em formulários
Os critérios diretamente relacionados:
- 1.3.1 — Informação e Relacionamentos (A): rótulos e estrutura devem ser determináveis por programação
- 3.3.1 — Identificação de Erros (A): erros devem ser identificados em texto e descritos ao usuário
- 3.3.2 — Rótulos ou Instruções (A): campos que requerem input devem ter rótulos ou instruções claras
- 1.3.5 — Identificar o Propósito do Input (AA): campos de informação pessoal devem ter
autocompletecorreto - 4.1.2 — Nome, Função, Valor (A): todos os componentes de interface devem ter nome acessível
O problema mais comum: label não associada
<!-- ❌ Texto "solto" acima do campo — leitores de tela não sabem que pertence ao input -->
<p>Nome completo</p>
<input type="text" />
<!-- ❌ Placeholder não é label — desaparece ao digitar, não é anunciado corretamente -->
<input type="text" placeholder="Nome completo" />
<!-- ✅ Label associada com for + id correspondente -->
<label for="nome">Nome completo</label>
<input id="nome" type="text" name="nome" autocomplete="name" />
O atributo for do <label> deve corresponder exatamente ao id do <input>. Quando associados, clicar na label também foca o campo — benefício para usuários com dificuldade motora.
Em React, use htmlFor:
<label htmlFor="nome" className="block text-sm font-semibold text-slate-700 mb-1">
Nome completo
</label>
<input id="nome" name="nome" type="text" autocomplete="name" />
Campos obrigatórios
<!-- ❌ Asterisco sem explicação — ambíguo para quem não conhece a convenção -->
<label for="email">E-mail *</label>
<input id="email" type="email" />
<!-- ✅ Marcação semântica + explicação visual + texto para leitores de tela -->
<p class="text-xs text-gray-500 mb-4">
Campos marcados com
<span aria-hidden="true">*</span>
<span class="sr-only">asterisco</span>
são obrigatórios.
</p>
<label for="email">
E-mail
<span aria-hidden="true" class="text-red-600 ml-0.5">*</span>
<span class="sr-only">(obrigatório)</span>
</label>
<input
id="email"
type="email"
name="email"
required
aria-required="true"
autocomplete="email"
/>
O aria-hidden="true" no asterisco visual evita que o leitor de tela leia "E-mail asterisco". O <span class="sr-only"> fornece o texto alternativo correto: "E-mail (obrigatório)".
Mensagens de erro acessíveis
Erros de formulário precisam ser:
- Descritos em texto (não apenas por cor vermelha)
- Associados programaticamente ao campo que gerou o erro
- Anunciados automaticamente ao usuário de leitor de tela
<!-- ❌ Erro sem associação ao campo e sem anúncio automático -->
<input id="cpf" type="text" class="border-red-500" />
<p class="text-red-500 text-sm">CPF inválido</p>
<!-- ✅ Erro associado com aria-describedby + anunciado com role="alert" -->
<label for="cpf">CPF</label>
<input
id="cpf"
type="text"
name="cpf"
aria-invalid="true"
aria-describedby="cpf-error"
class="border-red-500 ..."
/>
<p id="cpf-error" role="alert" class="text-sm text-red-600 mt-1">
CPF inválido. Use o formato: 000.000.000-00
</p>
O role="alert" faz o leitor de tela anunciar a mensagem de erro imediatamente, sem o usuário precisar navegar manualmente até ela. O aria-describedby associa o texto de erro ao campo, então quando o usuário foca o input, o leitor de tela lê "CPF, inválido, CPF inválido. Use o formato: 000.000.000-00."
Em React com controle de estado:
function Campo({
id,
label,
type = 'text',
error,
...props
}: {
id: string
label: string
type?: string
error?: string
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div>
<label
htmlFor={id}
className="block text-sm font-semibold text-slate-700 mb-1"
>
{label}
</label>
<input
id={id}
name={id}
type={type}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
className={`w-full rounded-lg border px-3 py-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600
${error ? 'border-red-500 bg-red-50' : 'border-gray-300'}`}
{...props}
/>
{error && (
<p id={`${id}-error`} role="alert" className="text-xs text-red-600 mt-1">
{error}
</p>
)}
</div>
)
}
Uso:
<Campo
id="email"
label="E-mail"
type="email"
error={erros.email}
autoComplete="email"
/>
Agrupamento de campos relacionados
Para grupos de radio buttons, checkboxes, ou seções lógicas de formulário, use <fieldset> e <legend>:
<!-- ❌ Radio buttons sem contexto — leitor de tela anuncia só o valor, sem saber o que representa -->
<p>Tipo de conta</p>
<input type="radio" id="pf" name="tipo" value="pf" />
<label for="pf">Pessoa Física</label>
<input type="radio" id="pj" name="tipo" value="pj" />
<label for="pj">Pessoa Jurídica</label>
<!-- ✅ fieldset + legend fornece contexto ao grupo -->
<fieldset class="border border-gray-200 rounded-xl p-4">
<legend class="text-sm font-semibold text-slate-700 px-2">
Tipo de conta
</legend>
<div class="space-y-2 mt-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="tipo"
value="pf"
class="w-4 h-4 accent-blue-600"
/>
<span class="text-sm text-slate-700">Pessoa Física</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="tipo"
value="pj"
class="w-4 h-4 accent-blue-600"
/>
<span class="text-sm text-slate-700">Pessoa Jurídica</span>
</label>
</div>
</fieldset>
O leitor de tela anuncia: "Tipo de conta — agrupamento. Pessoa Física, botão de rádio, não marcado." O usuário sabe o contexto do grupo antes de interagir com os itens.
Autocomplete para campos pessoais
O critério 1.3.5 do WCAG AA exige autocomplete correto em campos de informação pessoal. Isso também ajuda usuários com dificuldade cognitiva e quem usa gerenciadores de senha:
<!-- Informações de contato -->
<input type="text" name="nome" autocomplete="name" />
<input type="email" name="email" autocomplete="email" />
<input type="tel" name="telefone" autocomplete="tel" />
<!-- Endereço -->
<input type="text" name="rua" autocomplete="street-address" />
<input type="text" name="cidade" autocomplete="address-level2" />
<input type="text" name="estado" autocomplete="address-level1" />
<input type="text" name="cep" autocomplete="postal-code" />
<!-- Cartão de crédito -->
<input type="text" name="titular" autocomplete="cc-name" />
<input type="text" name="numero" autocomplete="cc-number" />
<input type="text" name="validade" autocomplete="cc-exp" />
<input type="text" name="cvv" autocomplete="cc-csc" />
Campos de senha novos e confirmação de senha usam autocomplete="new-password". Campo de login: autocomplete="current-password".
Select acessível
<!-- ❌ Select sem label -->
<select name="estado">
<option value="">Selecione o estado</option>
...
</select>
<!-- ✅ Com label associada + valor default descritivo -->
<label for="estado" class="block text-sm font-semibold text-slate-700 mb-1">
Estado
</label>
<select
id="estado"
name="estado"
required
aria-required="true"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600
bg-white"
>
<option value="" disabled selected>Selecione o estado</option>
<option value="SP">São Paulo</option>
<option value="RJ">Rio de Janeiro</option>
<!-- ... -->
</select>
Botão de envio
<!-- ❌ Imagem sem alternativa textual -->
<input type="image" src="enviar.png" />
<!-- ❌ Div como botão de envio — não funciona com Enter no campo -->
<div onclick="enviarFormulario()">Enviar cadastro</div>
<!-- ✅ Button com tipo explícito e texto descritivo -->
<button
type="submit"
class="w-full bg-blue-600 text-white font-semibold px-6 py-3 rounded-xl
hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600
focus-visible:ring-offset-2 transition-colors"
>
Enviar cadastro
</button>
type="submit" garante que o formulário é enviado ao pressionar Enter em qualquer campo. O texto do botão deve descrever a ação — não apenas "Enviar", mas "Enviar cadastro" ou "Confirmar pedido".
Checklist de formulário acessível
Labels e estrutura
- Todo
<input>,<select>,<textarea>tem<label>comfor/htmlForassociado - Placeholder não substitui label — é complemento
- Grupos radio/checkbox têm
<fieldset>+<legend>
Campos obrigatórios
-
required+aria-required="true"nos campos obrigatórios - Indicação visual de obrigatoriedade explicada no início do formulário
Erros
- Erros associados ao campo com
aria-describedby -
aria-invalid="true"adicionado ao campo com erro - Mensagem de erro com
role="alert"para anúncio automático - Texto do erro é descritivo e indica como corrigir
Outros
- Campos pessoais têm
autocompletecorreto (WCAG 1.3.5) - Botão de envio é
<button type="submit">com texto descritivo - Foco visível em todos os campos (
focus-visible:ring-2) - Nenhuma informação comunicada apenas por cor
Critérios WCAG: 1.3.1 (A) · 3.3.1 (A) · 3.3.2 (A) · 4.1.2 (A) · 1.3.5 (AA)
Verifique seus formulários agora: Analisador de acessibilidade gratuito do Web para Todos