Acessibilidade em HTML: alt, label, foco e atributos ARIA
O que se resolve só com HTML, quando o ARIA entra e como auditar a página no terminal com axe-core: 10 violações plantadas, corrigidas até zero.
Acessibilidade em HTML é o trabalho de fazer uma página continuar utilizável
quando a pessoa não enxerga a tela, não usa mouse ou precisa ampliar o conteúdo.
Na prática, boa parte desse trabalho começa com a tag certa: button, label,
alt e lang já carregam significado e comportamento que o navegador e as
tecnologias assistivas entendem. ARIA entra depois, quando o HTML não oferece o
componente de que você precisa.
Pense na página como uma clínica com placas, balcões e corredores. O label é
a placa que identifica cada balcão; o texto alternativo é a descrição do que
uma imagem comunica; a ordem de foco é o caminho sem obstáculos entre os
atendimentos. Se uma placa some, talvez quem enxerga ainda adivinhe o destino,
mas quem depende do leitor de tela recebe um corredor sem indicação. No HTML,
essas “placas” viram nomes acessíveis, relações entre elementos e uma sequência
de teclado previsível — é esse comportamento técnico que vamos testar.
Para sair da teoria, esta lição usa uma página só: o agendamento de consulta da
Clínica Veterinária Pata Amiga. Ela começa com dez violações e termina com zero
no axe-core. Depois, fazemos os testes de teclado e leitor de tela que o
auditor automático não consegue decidir sozinho.
A página da clínica, com defeito de propósito
Este é o arquivo agendamento.html. Ele parece pronto no navegador: tem logo,
título, foto do consultório, formulário e botão verde.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div class="topo">
<a href="/"><img src="/img/logo-pata-amiga.png" /></a>
<a href="/emergencia" class="icone">
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M10 3v14M3 10h14" /></svg>
</a>
</div>
<h1>Clínica Veterinária Pata Amiga</h1>
<h3>Agende a consulta do seu pet</h3>
<img src="/img/consultorio.jpg" />
<form action="/agendar" method="post">
<input type="text" name="tutor" placeholder="Nome do tutor" />
<input type="text" name="pet" placeholder="Nome do pet" tabindex="3" />
<select name="especialidade">
<option>Clínica geral</option>
<option>Odontologia</option>
</select>
<div role="checkbox" class="check">Quero lembrete por WhatsApp</div>
<div class="botao" onclick="enviar()">Agendar consulta</div>
<button type="submit">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8h12M9 3l5 5-5 5" /></svg>
</button>
</form>
</body>
</html>Nada aqui é caricatura. Cada defeito desses aparece em site real todo dia: a
div que virou botão, o ícone sem nome, o tabindex que alguém pôs para
“arrumar a ordem”, o título que pulou de h1 para h3 porque o h2 estava
grande demais.
O auditor cabe em dezesseis linhas de Node
Você não precisa de extensão nem de serviço pago para descobrir isso. O
axe-core é a mesma biblioteca que roda por trás do Lighthouse e do axe
DevTools, e ela funciona sobre o jsdom — o DOM de mentira que o Node monta a
partir de um arquivo HTML.
npm init -y
npm i jsdom axe-coreO script carrega o arquivo, injeta o axe dentro daquela janela e pede o relatório:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import axe from 'axe-core';
const arquivo = process.argv[2];
const dom = new JSDOM(readFileSync(arquivo, 'utf8'), { runScripts: 'outside-only' });
const { window } = dom;
window.eval(axe.source);
const resultado = await window.axe.run(window.document);
console.log(`${resultado.violations.length} violações em ${arquivo}`);
for (const v of resultado.violations) {
console.log(`[${v.impact}] ${v.id} — ${v.help}`);
for (const no of v.nodes) console.log(` ${no.target.join(' ')}`);
}Rodando com node auditar.mjs agendamento.html:
Cada linha traz o impacto, o identificador da regra e o seletor do elemento
culpado. Repare no region: nove elementos listados de uma vez. É a página
inteira fora de qualquer marco de navegação, porque nenhuma tag de
HTML semântico foi usada — só div.
O que o auditor deixou passar
Antes de sair corrigindo, vale olhar o outro lado do relatório. O axe também
devolve o que passou e o que ele não conseguiu decidir. No mesmo script,
logo depois do axe.run, acrescente:
const label = resultado.passes.find((p) => p.id === 'label');
console.log(`regra "label": ${label ? 'passou' : 'não avaliada'}`);
for (const no of label?.nodes ?? []) {
console.log(` ${no.target.join(' ')} passou por: ${no.any.map((c) => c.id).join(', ')}`);
}
console.log(
`regras que o axe não conseguiu decidir: ${resultado.incomplete.map((i) => i.id).join(', ')}`,
);Os dois campos passaram na regra de rótulo — pelo critério non-empty-placeholder.
Ou seja: o placeholder conta como nome do campo na especificação, e por isso a
ferramenta não reclama. Mas placeholder some quando você digita, não vira alvo de
clique e some no autopreenchimento. Aprovado pelo auditor não é sinônimo de bom
de usar.
A primeira regra do ARIA é não usar ARIA
ARIA é um conjunto de atributos que descreve papel, estado e nome de um
elemento para a tecnologia assistiva. Ele não muda comportamento nenhum:
escrever role="button" numa div não faz ela receber foco, não faz o Enter
disparar o clique e não faz o navegador enviar o formulário.
A regra oficial da W3C é literal: se existe um elemento HTML nativo com o papel
que você quer, use o elemento. Veja o que acontece quando alguém tenta resolver
tudo com atributo. O lang, o title e o main estão certos aqui de
propósito: tudo que sobrar no relatório é ARIA mal usado.
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<title>Lista de espera — Pata Amiga</title>
</head>
<body>
<main>
<h1>Lista de espera</h1>
<div role="checkbox" tabindex="0">Avisar por WhatsApp</div>
<button type="button" aria-expanded="sim">Ver horários</button>
<input type="text" name="pet" aria-labelledby="rotulo-pet" />
<ul role="listbox">
<li>Nina</li>
<li>Thor</li>
</ul>
</main>
</body>
</html>Leia com calma, porque cada linha ensina uma coisa:
role="checkbox"obriga você a manteraria-checkedatualizado no JavaScript. Um<input type="checkbox">faria isso sozinho.aria-expanded="sim"não existe. O valor tem que sertrueoufalse, em inglês, sempre string.aria-labelledby="rotulo-pet"aponta para umidque não está na página. O campo ficou sem nome nenhum — pior do que se ninguém tivesse tentado.- O
role="listbox"na<ul>gerou três violações de uma vez: a lista virou um campo de escolha sem nome, sem os filhos que esse papel exige, e os<li>deixaram de estar dentro de uma lista.
Um atributo errado estraga mais do que atributo nenhum. Quando você não tem certeza, tire o ARIA e use a tag.
Teclado: a ordem de foco e o outline que você apagou
Nem toda pessoa usa mouse. Tetraplegia, tremor essencial, lesão por esforço
repetitivo, mouse quebrado às 23h — todos levam ao mesmo lugar: navegar de
Tab em Tab.
O navegador visita primeiro os elementos com tabindex positivo, em ordem
crescente, e só depois os elementos naturalmente focáveis, na ordem do
documento. Dá para simular isso lendo o arquivo:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const FOCAVEIS = 'a[href], button, input, select, textarea, [tabindex]';
const { window } = new JSDOM(readFileSync(process.argv[2], 'utf8'));
const elementos = [...window.document.querySelectorAll(FOCAVEIS)].map((el, posicao) => ({
el,
posicao,
tabindex: Number(el.getAttribute('tabindex') ?? 0),
}));
const ordem = [
...elementos.filter((e) => e.tabindex > 0).sort((a, b) => a.tabindex - b.tabindex || a.posicao - b.posicao),
...elementos.filter((e) => e.tabindex === 0),
];
const rotulo = (el) =>
el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent.trim() || el.getAttribute('href') || '(sem nome)';
ordem.forEach((e, i) => console.log(`Tab ${i + 1}: <${e.el.tagName.toLowerCase()}> ${rotulo(e.el)}`));
const invisiveis = [...window.document.querySelectorAll('div[onclick], div[role="checkbox"]')].map((el) => `.${el.className}`);
console.log(`${ordem.length} paradas. Fora da ordem: ${invisiveis.join(', ') || '(nenhuma)'}`);Duas coisas graves nessa saída. A primeira: o tabindex="3" fez o campo do pet
ser o primeiro da página, antes até do logo. A segunda, pior: o botão
“Agendar consulta” e a caixa do lembrete não aparecem na lista. São div,
e div não recebe foco. Quem navega por teclado nunca chega neles.
O contorno de foco não é sujeira visual
O outro pecado clássico mora no CSS. Este arquivo é o estilo.css da clínica:
.botao {
background: #7ac143;
color: #ffffff;
border-radius: 8px;
padding: 12px 20px;
}
*:focus {
outline: none;
}
.icone:focus {
outline: 0;
}É comum apagar o contorno sem perceber, e trabalhoso encontrar o problema depois. Um
verificador de pouco mais de trinta linhas com css-tree encontra todos de uma
vez:
import { readFileSync } from 'node:fs';
import * as csstree from 'css-tree';
const arvore = csstree.parse(readFileSync(process.argv[2], 'utf8'), { positions: true });
const apagados = [];
let temFocusVisible = false;
csstree.walk(arvore, {
visit: 'Rule',
enter(regra) {
const seletor = csstree.generate(regra.prelude);
if (seletor.includes(':focus-visible')) temFocusVisible = true;
csstree.walk(regra.block, {
visit: 'Declaration',
enter(decl) {
const valor = csstree.generate(decl.value);
if (decl.property === 'outline' && (valor === 'none' || valor === '0')) {
apagados.push({ seletor, valor, linha: decl.loc.start.line });
}
},
});
},
});
for (const a of apagados) {
console.log(`linha ${a.linha}: "${a.seletor}" apaga o foco com outline: ${a.valor}`);
}
console.log(
apagados.length === 0
? 'nenhum outline apagado'
: `${apagados.length} regra(s) apagam o foco; :focus-visible no arquivo? ${temFocusVisible ? 'sim' : 'NÃO'}`,
);A correção não é aceitar o contorno feio do navegador: é desenhar o seu. A
pseudo-classe :focus-visible só pinta quando o foco veio do teclado, então o
clique de mouse continua limpo. A forma como essa regra vence outras declarações
segue a especificidade do CSS. No
estilo-ok.css, ela entra no lugar das duas que apagavam — e o verificador
passa a não achar nada:
:focus-visible {
outline: 3px solid #3d6b1f;
outline-offset: 2px;
}Texto alternativo: imagem, ícone e botão só com ícone
alt não é legenda nem descrição de catálogo. É o que a imagem faz na
página, escrito em palavras. A mesma foto muda de alt conforme o lugar:
| onde a imagem está | função dela | alt certo |
|---|---|---|
| logo dentro do link para a home | é o link | alt="Clínica Veterinária Pata Amiga" |
| foto do consultório no meio do texto | mostra o ambiente | alt="Veterinária examinando um gato cinza sobre a mesa do consultório" |
| textura verde atrás do título | pura decoração | alt="" |
| ícone dentro de um botão que já tem texto | repetiria o texto | aria-hidden="true" no svg |
| gráfico de vacinas em dia | carrega o dado | o número no alt, ou a tabela ao lado |
Duas armadilhas aparecem sempre. A primeira: imagem decorativa leva alt="",
não fica sem alt. Sem o atributo, o leitor de tela costuma ler o nome do
arquivo — imagine ouvir “IMG underline 2024 underline final v2 ponto jpeg”. A
segunda: em <a href="/"><img ...></a>, o alt da imagem é o texto do link.
Vazio ali, o link fica mudo, e foi por isso que o link-name apareceu na
auditoria. A lição de imagens em HTML entra no resto
dos atributos da tag.
Nome acessível: label, aria-label e aria-labelledby
“Nome acessível” é o texto que a tecnologia assistiva anuncia quando o foco
chega num elemento. O navegador calcula esse nome seguindo uma ordem de
prioridade, e o axe expõe esse cálculo. Este é o arquivo nomes.html:
<button id="a"><svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" /></svg></button>
<button id="b" aria-label="Remover Nina da lista de espera">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
<button id="c">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
<span class="sr-only">Remover Nina da lista de espera</span>
</button>
<input id="d" placeholder="Nome do pet" />
<label for="e">Nome do pet</label>
<input id="e" placeholder="Ex.: Nina" />
<h2 id="titulo-secao">Pets em espera</h2>
<section id="f" aria-labelledby="titulo-secao"></section>E este script pergunta ao axe qual nome cada um recebeu:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import axe from 'axe-core';
const { window } = new JSDOM(readFileSync('nomes.html', 'utf8'), { runScripts: 'outside-only' });
window.eval(axe.source);
window.axe.setup(window.document);
for (const id of ['a', 'b', 'c', 'd', 'e', 'f']) {
const el = window.document.getElementById(id);
const virtual = window.axe.utils.getNodeFromTree(el);
const nome = window.axe.commons.text.accessibleTextVirtual(virtual);
console.log(`#${id} <${el.tagName.toLowerCase()}> -> ${nome ? `"${nome}"` : '(sem nome acessível)'}`);
}O botão #a é anunciado como “botão”, ponto. Ninguém descobre o que ele faz.
Os botões #b e #c chegam ao mesmo nome por caminhos diferentes: o #b com
aria-label, o #c com um texto que o CSS esconde da tela mas mantém no
documento. Prefira o #c quando puder — texto de verdade é traduzido pelo
tradutor do navegador e é encontrado pelo Ctrl+F da página.
Já os campos #d e #e recebem o mesmo nome, e é aí que mora a pegadinha da
seção anterior: o placeholder resolve o nome, mas não resolve o uso. Use
<label for="..."> sempre — inclusive porque clicar no rótulo foca o campo de
graça. O formulário inteiro está detalhado em
formulário em HTML.
aria-expanded e aria-live: os dois que valem aprender cedo
Depois de escolher as tags certas, sobram os componentes que o HTML não tem pronto. Aí sim ARIA ganha função. Dois atributos cobrem quase tudo que uma página institucional precisa.
aria-expanded conta o estado de um gatilho que abre e fecha alguma coisa.
aria-live="polite" avisa que aquele trecho vai mudar sozinho e que o leitor de
tela deve anunciar a mudança quando terminar a frase atual.
<button type="button" aria-expanded="false" aria-controls="painel-horarios">
Quais são os horários de atendimento?
</button>
<div id="painel-horarios" hidden>
<p>Segunda a sexta, das 8h às 19h. Sábado, das 8h às 13h.</p>
</div>
<p role="status" aria-live="polite" id="aviso"></p>O erro comum é abrir o painel e esquecer de trocar o atributo — a tela mostra aberto e o leitor de tela continua dizendo “recolhido”. As duas coisas mudam juntas, sempre. Este é o clique simulado sobre a página corrigida que fecha o artigo, onde esse bloco já está montado:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM(readFileSync('agendamento-ok.html', 'utf8'));
const doc = window.document;
const gatilho = doc.querySelector('[aria-controls="painel-horarios"]');
const painel = doc.getElementById('painel-horarios');
const aviso = doc.getElementById('aviso');
const estado = (quando) =>
console.log(
`${quando}: aria-expanded=${gatilho.getAttribute('aria-expanded')} | painel hidden=${painel.hasAttribute('hidden')} | aviso="${aviso.textContent}"`,
);
estado('antes do clique ');
const aberto = gatilho.getAttribute('aria-expanded') === 'true';
gatilho.setAttribute('aria-expanded', String(!aberto));
painel.toggleAttribute('hidden', aberto);
aviso.textContent = 'Horários exibidos.';
estado('depois do clique ');O aria-live vale ouro no formulário: “Consulta agendada para 12/08 às 14h” ou
“Confira o campo de telefone” precisam chegar a quem não está olhando para a
tela. Se a validação for a nativa do navegador, boa parte disso vem pronta —
veja validação de formulário sem JavaScript.
Contraste e tamanho de alvo: o que o auditor não viu
Lembra que color-contrast ficou indecidível? Contraste é a razão entre a
luminância do texto e a do fundo, e o cálculo é público. Vinte e cinco linhas de
Node resolvem:
const canal = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
function luminancia(hex) {
const [r, g, b] = hex.match(/\w\w/g).map((p) => canal(parseInt(p, 16) / 255));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contraste(a, b) {
const [claro, escuro] = [luminancia(a), luminancia(b)].sort((x, y) => y - x);
return (claro + 0.05) / (escuro + 0.05);
}
const pares = [
['#7ac143', '#ffffff', 'verde da marca no botão'],
['#4f8a2b', '#ffffff', 'o mesmo verde, um pouco mais escuro'],
['#3d6b1f', '#ffffff', 'verde escuro o bastante'],
['#9aa0a6', '#ffffff', 'cinza do placeholder'],
['#5f6368', '#ffffff', 'cinza do texto de apoio'],
];
for (const [frente, fundo, nome] of pares) {
const r = contraste(frente, fundo);
const nota = r >= 4.5 ? 'passa AA' : r >= 3 ? 'só AA para texto grande' : 'reprovado';
console.log(`${frente} sobre ${fundo} ${r.toFixed(2)}:1 ${nota} (${nome})`);
}O verde bonito da marca dá 2,20:1 com texto branco em cima — menos da metade do
mínimo de 4,5:1 que a WCAG pede para texto normal. E olhe o segundo número:
escurecer “um pouquinho” levou a 4,20 e ainda reprova. Foi preciso ir até
#3d6b1f para passar. Esse é o tipo de conta que só a medição resolve; o olho
erra.
O tamanho do alvo é o irmão esquecido. Um botão de 24 pixels de altura funciona
com o ponteiro preciso do mouse, mas vira obstáculo para dedo grande, tremor ou
celular no ônibus. A
WCAG 2.2 pede 24×24 CSS pixels no mínimo; 44×44 é o número que o mercado
adotou. Um min-height: 44px no botão já cobre o caso.
A página corrigida: de dez violações a zero
Nenhuma correção aqui usou biblioteca, framework ou atributo exótico. Foi
trocar div por tag certa, escrever rótulo e alt, e pôr a página dentro de
marcos de navegação. A sanfona de horários da seção anterior entrou no mesmo
arquivo — é ela que aparece como última parada do teclado, logo adiante.
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<title>Agendar consulta — Clínica Veterinária Pata Amiga</title>
</head>
<body>
<header class="topo">
<a href="/"><img src="/img/logo-pata-amiga.png" alt="Clínica Veterinária Pata Amiga" /></a>
<a href="/emergencia" class="icone" aria-label="Atendimento de emergência 24h">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M10 3v14M3 10h14" />
</svg>
</a>
</header>
<main>
<h1>Agende a consulta do seu pet</h1>
<img
src="/img/consultorio.jpg"
alt="Veterinária examinando um gato cinza sobre a mesa do consultório"
/>
<h2>Dados do agendamento</h2>
<form action="/agendar" method="post">
<label for="tutor">Nome do tutor</label>
<input type="text" id="tutor" name="tutor" placeholder="Ex.: Ana Ribeiro" />
<label for="pet">Nome do pet</label>
<input type="text" id="pet" name="pet" placeholder="Ex.: Nina" />
<label for="especialidade">Especialidade</label>
<select id="especialidade" name="especialidade">
<option>Clínica geral</option>
<option>Odontologia</option>
</select>
<input type="checkbox" id="lembrete" name="lembrete" />
<label for="lembrete">Quero lembrete por WhatsApp</label>
<button type="submit">Agendar consulta</button>
</form>
<h2>Dúvidas frequentes</h2>
<button type="button" aria-expanded="false" aria-controls="painel-horarios">
Quais são os horários de atendimento?
</button>
<div id="painel-horarios" hidden>
<p>Segunda a sexta, das 8h às 19h. Sábado, das 8h às 13h.</p>
</div>
<p role="status" aria-live="polite" id="aviso"></p>
</main>
</body>
</html>E a ordem de foco, que era o defeito invisível:
Seis paradas viraram oito, todas na ordem em que a pessoa lê, e todas com nome. O botão principal, que antes não existia para o teclado, agora é a parada 7.
Dez minutos de teste manual que nenhuma ferramenta faz
A auditoria automática pega o defeito mecânico. Ela não sabe se o seu alt
descreve a imagem certa, nem se a ordem faz sentido para quem está preenchendo.
Faça estes três testes, nesta ordem, antes de considerar a página pronta.
Um: solte o mouse. Percorra a página inteira só de Tab. Você precisa ver
o foco o tempo todo, chegar em tudo que é clicável, acionar com Enter (links e
botões) ou Espaço (botões e caixas), e nunca ficar preso num trecho sem
conseguir sair.
Dois: ligue o leitor de tela. Ele já está instalado. No macOS, Cmd + F5
liga o VoiceOver. No Windows, o Narrador entra com Ctrl + Win + Enter; o NVDA
é gratuito e melhor. No Android é o TalkBack, no iPhone o VoiceOver. Ouça a
página do começo ao fim uma vez. Você vai identificar sozinho o “botão” sem
nome e o “link” que só diz “clique aqui”.
Três: dê zoom de 200% e reduza a janela. Boa parte dos problemas de baixa visão aparece aí — texto cortado, botão que sai da tela, rolagem horizontal.
| teste | ferramenta | quanto custa | o que ele acha |
|---|---|---|---|
| auditoria automática | axe-core no terminal ou Lighthouse no DevTools | zero | atributo faltando, nome vazio, contraste |
| navegação por teclado | a tecla Tab |
zero | ordem errada, foco invisível, armadilha de foco |
| leitor de tela | VoiceOver, Narrador, NVDA, TalkBack | zero | nome ruim, ordem de leitura, imagem mal descrita |
| contraste | script do artigo, ou o conta-gotas do DevTools | zero | cor que reprova na WCAG |
Em vídeo e áudio a lógica é a mesma, com um item extra: legenda. Isso está em vídeo e áudio em HTML.
Por onde seguir
Escolha uma página que você já fez, rode o auditor nela e conserte a lista de
cima para baixo, começando pelo critical. Você vai gastar mais tempo lendo o
relatório do que corrigindo — quase tudo é label, alt e tag trocada.
Depois disso, a próxima parada da trilha de HTML é mídia na página; e se quiser rever a ordem completa dos assuntos, o guia de HTML do zero mostra onde cada lição entra.
Perguntas frequentes
Preciso saber ARIA para fazer site acessível?
Botão só com ícone precisa de texto escondido ou de aria-label?
Posso usar placeholder no lugar do label?
Imagem decorativa leva alt vazio ou nenhum alt?
Uma auditoria automática garante que o site está acessível?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com jsdom 30.0.1 e axe-core 4.13.0, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — ARIA — developer.mozilla.org
- W3C — Using ARIA: rule 1 — w3.org
- WCAG 2.2 — Contrast (Minimum) — w3.org
- Deque — axe-core rule descriptions — dequeuniversity.com


