Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori13 min de leitura

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.

html
<!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.

bash
npm init -y
npm i jsdom axe-core

O script carrega o arquivo, injeta o axe dentro daquela janela e pede o relatório:

js
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:

10 violações em agendamento.html [critical] aria-required-attr — Required ARIA attributes must be provided .check [critical] button-name — Buttons must have discernible text button [serious] document-title — Documents must have <title> element to aid in navigation html [moderate] heading-order — Heading levels should only increase by one h3 [serious] html-has-lang — <html> element must have a lang attribute html [critical] image-alt — Images must have alternative text img[src$="logo-pata-amiga.png"] img[src$="consultorio.jpg"] [serious] link-name — Links must have discernible text a[href="/"] .icone [moderate] region — All page content should be contained by landmarks a[href="/"] h1 h3 img[src$="consultorio.jpg"] input[name="tutor"] input[name="pet"] select .check .botao [critical] select-name — Select element must have an accessible name select [serious] tabindex — Elements should not have tabindex greater than zero input[name="pet"]

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:

js
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(', ')}`,
);
regra "label": passou input[name="tutor"] passou por: non-empty-placeholder input[name="pet"] passou por: non-empty-placeholder regras que o axe não conseguiu decidir: color-contrast, landmark-one-main, page-has-heading-one

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.

html
<!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>
6 violações em aria-demais.html [serious] aria-input-field-name — ARIA input fields must have an accessible name ul [critical] aria-required-attr — Required ARIA attributes must be provided div [critical] aria-required-children — Certain ARIA roles must contain particular children ul [critical] aria-valid-attr-value — ARIA attributes must conform to valid values button [critical] label — Form elements must have labels input [serious] listitem — <li> elements must be contained in a <ul> or <ol> li:nth-child(1) li:nth-child(2)

Leia com calma, porque cada linha ensina uma coisa:

  • role="checkbox" obriga você a manter aria-checked atualizado no JavaScript. Um <input type="checkbox"> faria isso sozinho.
  • aria-expanded="sim" não existe. O valor tem que ser true ou false, em inglês, sempre string.
  • aria-labelledby="rotulo-pet" aponta para um id que 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:

js
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)'}`);
Tab 1: <input> pet Tab 2: <a> / Tab 3: <a> /emergencia Tab 4: <input> tutor Tab 5: <select> especialidade Tab 6: <button> (sem nome) 6 paradas. Fora da ordem: .check, .botao

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:

css
.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:

js
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'}`,
);
linha 9: "*:focus" apaga o foco com outline: none linha 13: ".icone:focus" apaga o foco com outline: 0 2 regra(s) apagam o foco; :focus-visible no arquivo? 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:

css
:focus-visible {
  outline: 3px solid #3d6b1f;
  outline-offset: 2px;
}
nenhum outline apagado

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:

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:

js
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)'}`);
}
#a <button> -> (sem nome acessível) #b <button> -> "Remover Nina da lista de espera" #c <button> -> "Remover Nina da lista de espera" #d <input> -> "Nome do pet" #e <input> -> "Nome do pet" #f <section> -> "Pets em espera"

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.

html
<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:

js
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  ');
antes do clique : aria-expanded=false | painel hidden=true | aviso="" depois do clique : aria-expanded=true | painel hidden=false | aviso="Horários exibidos."

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:

js
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})`);
}
#7ac143 sobre #ffffff 2.20:1 reprovado (verde da marca no botão) #4f8a2b sobre #ffffff 4.20:1 só AA para texto grande (o mesmo verde, um pouco mais escuro) #3d6b1f sobre #ffffff 6.32:1 passa AA (verde escuro o bastante) #9aa0a6 sobre #ffffff 2.64:1 reprovado (cinza do placeholder) #5f6368 sobre #ffffff 6.05:1 passa AA (cinza do texto de apoio)

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.

html
<!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>
0 violações em agendamento-ok.html

E a ordem de foco, que era o defeito invisível:

Tab 1: <a> / Tab 2: <a> Atendimento de emergência 24h Tab 3: <input> tutor Tab 4: <input> pet Tab 5: <select> especialidade Tab 6: <input> lembrete Tab 7: <button> Agendar consulta Tab 8: <button> Quais são os horários de atendimento? 8 paradas. Fora da ordem: (nenhuma)

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.

  • html
  • acessibilidade
  • aria
  • teclado
  • leitor de tela

Perguntas frequentes

Preciso saber ARIA para fazer site acessível?
Não para começar. A maior parte do trabalho é escolher a tag certa, escrever label e alt, e não apagar o contorno de foco. ARIA entra quando você monta um componente que não existe em HTML, como uma sanfona ou um combobox.
Botão só com ícone precisa de texto escondido ou de aria-label?
Os dois funcionam e o leitor de tela anuncia o mesmo nome. O texto escondido numa classe utilitária tem uma vantagem prática: ele é traduzido pelo tradutor automático do navegador e aparece na busca por texto da página, coisa que o aria-label não faz.
Posso usar placeholder no lugar do label?
Não. O placeholder some assim que a pessoa começa a digitar, tem contraste baixo por padrão e não vira alvo de clique. O auditor até deixa passar, porque tecnicamente o placeholder dá um nome ao campo, mas o problema de uso continua.
Imagem decorativa leva alt vazio ou nenhum alt?
Leva alt vazio, escrito como alt="". Sem o atributo, o leitor de tela tenta adivinhar e costuma ler o nome do arquivo. Com o atributo vazio, a imagem é ignorada de propósito, que é exatamente o que você quer.
Uma auditoria automática garante que o site está acessível?
Não. Na página deste artigo o axe encontrou 10 violações, mas deixou três regras como indecidíveis e aprovou os dois campos que só tinham placeholder no lugar do label. Ferramenta acha o defeito mecânico; teclado e leitor de tela acham o resto.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. MDN — ARIA — developer.mozilla.org
  2. W3C — Using ARIA: rule 1 — w3.org
  3. WCAG 2.2 — Contrast (Minimum) — w3.org
  4. Deque — axe-core rule descriptions — dequeuniversity.com

Continue por aqui