Você já preencheu um formulário, apertou "Enviar", e nada aconteceu? O botão não respondeu, a página não mudou, e você ficou ali, olhando pra tela, tentando adivinhar o que deu errado?
Agora imagina isso sem poder ver a tela.
É mais ou menos o que acontece todos os dias com pessoas usuárias de leitores de tela. A gente caprichou na validação: o campo fica com a borda vermelha, aparece um "E-mail inválido" logo abaixo, tudo bonito. E funciona lindamente... para quem enxerga. Para quem depende de tecnologia assistiva, muitas vezes acontece o famoso nada.
Neste artigo vamos ver como fazer a validação funcionar pra todo mundo — e, de quebra, entender por que tanta gente erra isso (quase sempre por confiar só no que vê na tela).
ps: testei os exemplos com o NVDA. Como o comportamento muda entre leitores de tela e navegadores, o que está escrito aqui é com base no que experienciei com essa ferramenta. Testar no seu contexto continua sendo obrigatório!
Dito isso, vamos ao que interessa:
Por que os leitores de tela "não veem" seus erros
Um leitor de tela não enxerga a página do jeito que a gente enxerga. Ele navega uma representação estruturada dela - a accessibility tree - e, num dado momento, o foco dele está em um ponto só.
Se algo muda em outro lugar (uma mensagem que aparece embaixo do input, um resumo de erros no topo, etc...), o leitor de tela não tem como adivinhar que aquilo aconteceu. Ele não fica varrendo a tela atrás de novidade.
O HTML tradicional descreve bem só o que existe quando a página carrega. Toda mudança posterior — ou seja, o TCHAM DO NEGOCIO de qualquer validação em JavaScript — precisa ser anunciada de propósito. E é exatamente pra isso que existem as live regions e alguns atributos ARIA que a gente vai conhecer agora.
Começando pelo básico: dizer QUAL campo e POR QUÊ
Antes de qualquer coisa "ao vivo", o mínimo é: quando um campo está inválido, a pessoa precisa saber qual campo é e por que deu ruim. Dois atributos resolvem isso.
<label for="email">E-mail</label>
<input
id="email"
type="email"
name="email"
inputmode="email"
aria-invalid="true"
aria-describedby="email-erro"
/>
<p id="email-erro" class="mensagem-erro">
Informe um e-mail válido, por exemplo: nome@dominio.com
</p>
Vamos analisar o código:
-
aria-invalid="true": marca o campo como inválido na árvore de acessibilidade. O leitor de tela passa a anunciar aquele input como "inválido". -
aria-describedby="email-erro": amarra a mensagem de erro ao campo. Quando o foco cair no input, o leitor de tela lê o label e a descrição do erro junto.
Calma que aqui tem uma pegadinha clássica: muita gente coloca
aria-invalid="true"direto no HTML estático, no carregamento da página. Não faça isso! Oaria-invalidé resultado de uma validação — ele deve ser ligado via JavaScript quando a validação falha, nunca antes. Marcar um campo obrigatório vazio como inválido assim que a página abre é bem bizarro: a pessoa ainda nem começou a preencher e já recebe um erro.Detalhe que economiza código: o valor padrão de
aria-invalidéfalse. Você não precisa espalhararia-invalid="false"em todo campo válido — basta adicionar/remover o"true"conforme o estado real.Detalhe bônus (e útil): o Chromium trata o
aria-describedbycomo uma live region. Na prática, isso faz NVDA e JAWS anunciarem a descrição assim que ela aparece no blur, mesmo semaria-live. É mais um motivo pra ele ser a aposta segura hoje. Só não coloquearia-liveno container do erro junto com oaria-describedbyno campo: isso causa double speak (a mensagem é lida duas vezes) em NVDA e JAWS.
E o aria-errormessage? Existe um atributo mais novo, feito justamente pra apontar a mensagem de erro sem sobrecarregar o aria-describedby. No papel, é a escolha "certa". Na prática, ele ainda vem com asteriscos: costuma não ser exposto no blur nem ao navegar campo a campo (muitas vezes só o cursor virtual encontra a mensagem), é lido como texto plano (sem semântica de lista/link) e só quando aria-invalid="true" está presente. Por enquanto, aria-describedby continua sendo a aposta segura.
Cuidado ao ler tabelas de suporte: o que o Can I Use mostra é suporte de navegador (quando o browser expõe o atributo pra API de acessibilidade), não de leitor de tela. E é o leitor de tela que decide se a mensagem vai ser anunciada. Os dois divergem: hoje o suporte de leitor de tela está razoável em JAWS, NVDA e VoiceOver do iOS, mas ainda irregular no resto (com bugs conhecidos no iOS dependendo de como você esconde a mensagem, e falta de suporte no TalkBack).
TLDR do navegador: chromium 135 | firefox 136 | Safari 16.4 | Opera 120 — mas isso não garante que seu leitor de tela vai anunciar.
Anunciando a mudança na hora: live regions
Associar o erro ao campo resolve o "quando eu chegar no campo". Mas e o momento em que o erro aparece? Aí entram as live regions.
Uma live region é uma parte da página que o leitor de tela fica "de olho": quando o conteúdo dela muda, ele anuncia. Você tem duas ferramentas principais, e a diferença entre elas é sobre urgência, não sobre importância:
<!-- Sucesso: pode esperar, não interrompe a pessoa -->
<div role="status">Cadastro concluído com sucesso.</div>
<!-- Erro que travou o envio: precisa de atenção AGORA -->
<div role="alert">Não foi possível enviar. Corrija os 2 erros abaixo.</div>
-
role="status": uma live region educada. Ela espera o leitor de tela terminar o que está falando pra então anunciar. Por baixo dos panos, já vem comaria-live="polite"earia-atomic="true". Use para confirmações e avisos que podem esperar meio segundo. -
role="alert": uma live region assertiva. Ela interrompe a fala atual do leitor de tela pra anunciar. Por baixo, já vem comaria-live="assertive"earia-atomic="true". Use com parcimônia, só pro que a pessoa precisa saber imediatamente.
Dois erros bem comuns aqui:
- Usar
aria-live="assertive"(ourole="alert") pra tudo. Interromper a pessoa no meio da frase, sem parar, a cada tecla digitada, é a versão sonora de alguém cutucando seu ombro sem descanso.politedeveria ser o seu padrão mental;assertiveé a exceção.- Empilhar
role="alert"earia-live="assertive"no mesmo elemento "pra garantir". Comorole="alert"já é assertivo, em vários leitores isso faz a mensagem ser anunciada duas vezes. Menos é mais.
O padrão que separa os profissionais: o resumo de erros
Erro campo a campo é ótimo. Mas quando a pessoa aperta "Enviar" e existem cinco campos com problema, obrigá-la a caçar cada um é péssima experiência.
Uma referência (popularizado pelo Design System do GOV.UK) é um resumo de erros no topo do formulário, que recebe o foco no envio e lista cada erro como um link direto pro campo correspondente:
<div class="resumo-erros" tabindex="-1" role="alert" aria-labelledby="resumo-titulo">
<h2 id="resumo-titulo">Há 2 problemas com o formulário</h2>
<ul>
<li><a href="#email">Informe um e-mail válido</a></li>
<li><a href="#password">A senha precisa ter ao menos 8 caracteres</a></li>
</ul>
</div>
const errors = document.querySelector('.resumo-erros');
errors.focus(); // move o foco -> o leitor de tela começa a ler o resumo
Repare que aqui a mensagem tem estrutura rica: um título, uma lista, links clicáveis. E isso nos leva a um ponto que quase ninguém comenta:
Live regions NÃO transmitem semântica rica. Se você jogar uma
<ul>com links dentro de uma<div role="alert">esperando que o leitor de tela anuncie "lista de 2 itens, link...", vai se decepcionar: boa parte deles lê só o texto corrido, sem as pistas de estrutura. Por isso o mecanismo principal aqui é o gerenciamento de foco (mover o foco pro container), e não a live region.
E o role="alert" no exemplo, então, é o quê? É uma rede de segurança, não um substituto do foco. O próprio GOV.UK usa os dois juntos de propósito: eles trocaram role="group" por role="alert" justamente porque VoiceOver (iOS/macOS) e TalkBack (Android) não anunciavam o erro só com o foco, por causa de bugs de navegador. Então o foco resolve o desktop e o role="alert" cobre o mobile.
Mas não saia empilhando os dois achando que é de graça. Essa combinação tem efeito colateral: nos próprios testes do GOV.UK,
role="alert"+ foco às vezes gera double speak ("Há um problema. Alerta. Você está num alerta."), às vezes lê só o heading, às vezes não lê nada — o comportamento varia bastante entre leitores. É uma escolha pragmática de "cobrir mais casos", com o custo de ocasionalmente falar demais. Teste antes de decidir se compensa no seu caso.
E quando a validação é assíncrona? aria-busy
Validação que depende do servidor — checar se um username já existe, por exemplo — cria uma janelinha em que a região está "trabalhando". O aria-busy="true" sinaliza isso e, em teoria, faz o leitor de tela segurar os anúncios até você marcar aria-busy="false".
<div id="status-username" role="status" aria-busy="true">
Verificando disponibilidade...
</div>
Atenção: o suporte a
aria-busyé irregular. Na maioria dos leitores de tela — o JAWS é a exceção notável — a região é lida mesmo estando "ocupada", o que estraga o efeito. Se você depende doaria-busypra segurar anúncios de um skeleton screen/shimmer, provavelmente não vai rolar como você imagina. Uma alternativa mais robusta é esconder a região comaria-hiddenenquanto carrega e revelá-la já pronta.
Ajuste fino: aria-atomic e aria-relevant
Esses dois controlam o que é anunciado quando a região muda.
O aria-atomic="true" faz o leitor anunciar a região inteira a cada mudança, não só o pedacinho que mudou. É essencial quando a frase só faz sentido completa:
<div aria-live="polite" aria-atomic="true">
Total: <span id="total">R$ 0,00</span>
</div>
Sem o aria-atomic="true", quando o #total muda, o leitor pode anunciar só "R$ 149,90", sem o "Total:" — e a pessoa perde o contexto. Com ele, a frase toda é lida.
Lembrando:
role="status"erole="alert"já vêm comaria-atomic="true"implícito, então raramente você precisa declarar isso neles.
O aria-relevant define que tipo de mudança dispara o anúncio: adições, remoções, mudança de texto. O valor é uma lista de tokens separada por espaço, e o padrão (aria-relevant="additions text") atende quase todos os casos.
Cuidado com a sintaxe: é
"additions text"mesmo, com espaço. Você vai ver muita documentação escrevendoadditions | text— mas ali o|é só notação pra dizer "esses são os tokens possíveis". Se você colar o pipe no HTML de verdade, vira token inválido.Um rápido disclaimer: o
aria-relevanté provavelmente o atributo ARIA mais copiado-e-colado por imitação, sem ninguém entender direito o que ele faz. Se você não consegue explicar por que precisa dele, você não precisa dele. E, assim como oaria-busy, o suporte dearia-relevantearia-atomicvaria bastante entre navegador e leitor de tela — trate os dois como ferramentas de ajuste fino num terreno que ainda não é 100% confiável.
"Mas funcionou na minha máquina!" — por que sua live region fica muda
Aqui está a parte que quase nenhuma documentação enfatiza o suficiente, e que explica a maioria dos "pqp MAS EU FIZ CERTO".
1. A região precisa existir ANTES do conteúdo mudar. Uma live region só anuncia mudanças. Se você injeta o elemento e o texto ao mesmo tempo, muitos leitores não têm o que comparar: pra eles, um nó novo apareceu com o texto já dentro, logo não houve mudança de texto. Resultado: silêncio. O jeito certo é ter a região vazia no DOM desde o carregamento e só então injetar o texto dentro dela.
2. Em SPA, o framework recria seus nós. Esse é o ASSASSINO em Angular, React e Vue. Olha esse exemplo que parece super ok e está quebrado:
<!-- Angular: parece certo, mas o leitor de tela fica mudo -->
<div *ngIf="erro" role="alert">{{ erro }}</div>
Quando erro passa de vazio pra preenchido, o Angular cria um nó novo com o texto já dentro. Do ponto de vista do leitor de tela, é idêntico ao problema nº 1: nó novo, sem evento de mudança de texto, sem anúncio.
A correção é manter a região sempre montada e mudar só o conteúdo de texto:
<!-- sempre presente no DOM; só o texto muda -->
<div role="alert">{{ erro }}</div>
Dica: em vez de rolar sua própria
<div aria-live>, use os announcers que os frameworks já entregam prontos: oLiveAnnouncerdo Angular CDK, o@react-aria/live-announcerno React, o@vue-a11y/announcerno Vue. Eles mantêm uma região estável fora da sua árvore de componentes e resolvem o timing por você. Reinventar isso na mão é onde mora a maioria dos bugs.
3. Timing importa. Se você adiciona a live region ao DOM dinamicamente e injeta texto no mesmo instante, a API de acessibilidade pode nem ter registrado a região ainda. Quando precisar criar a região na hora, dê um respiro pra ela ser reconhecida antes de preencher.
Atenção
Todos esses atributos podem estar sintaticamente perfeitos, o linter feliz da vida, e a experiência ainda ser silenciosa (literalmente). Live regions são conhecidas por serem inconsistentes entre navegadores e leitores de tela - a mesma marcação pode falar lindamente no NVDA + Firefox e emudecer no VoiceOver + Safari OU Talkback + Chromium based - As possibilidades são infinitas.
Não tem atalho pra isso: instale o NVDA (é gratuito), ligue o VoiceOver (já está no seu Mac) Ou o Talkback (que está no seu Android), e passe pelo seu formulário sem olhar para a tela. É desconfortável e lento na primeira vez. Também é a diferença entre uma validação que "funciona" e uma validação que funciona pra todo mundo.
Conclusão
Fazer validação acessível não é sobre decorar atributos ARIA e sair espalhando por aí. É sobre entender que o leitor de tela só sabe o que a gente conta pra ele:
Já dizia a w3:
No ARIA is better than Bad ARIA. Before using any ARIA, read this to understand why.
- Use
aria-invalid+aria-describedbypra dizer qual campo falhou e por quê — sempre via JavaScript, nunca no carregamento. - Use
role="status"pra avisos que podem esperar erole="alert"(com moderação) pro que é urgente. - Quando houver muitos erros no envio, prefira um resumo de erros com foco gerenciado (e o
role="alert"como rede de segurança pro mobile). - Trate
aria-busy,aria-atomicearia-relevantcomo ajuste fino, testando no par navegador/leitor real. - Garanta que suas live regions existam no DOM antes de receberem texto — e, em SPA, que fiquem sempre montadas.
- E o mais importante: abra um leitor de tela antes de dar o trabalho por concluído.
A validação visual você já domina. Agora é só garantir que ela funcione também pra quem não está vendo a tela.
Referências
- W3C WAI — ARIA19: Using role=alert or Live Regions to Identify Errors
- W3C WAI — ARIA21: Using aria-invalid to Indicate An Error Field
- MDN — alert role · status role · aria-invalid · Live regions
- WebAIM — Up and Coming ARIA (suporte atual de
aria-errormessagee afins) - Adrian Roselli — Exposing Field Errors · Live Region Support
- TetraLogical — Why are my live regions not working?
- Sara Soueidan — Accessible notifications with ARIA Live Regions (Parte 1 e Parte 2)
- When Your Live Region Isn't Live: Fixing aria-live in Angular, React, and Vue
- GOV.UK Design System — Error summary component














