Tutorial#wcag#formularios#html#aria#tutorial

Formulários acessíveis: guia completo com HTML semântico e ARIA

Como criar formulários web acessíveis com labels associadas, mensagens de erro corretas, campos obrigatórios e ARIA. Exemplos de código antes × depois para cada problema comum.

Web para Todos10 min de leitura

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 autocomplete correto
  • 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> com for/htmlFor associado
  • 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 autocomplete correto (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

Analise a acessibilidade do seu site agora

Gratuito, sem cadastro. Cole uma URL e receba um diagnóstico em segundos.

Abrir analisador