Menu fixo e WCAG 2.4.11: teste o foco escondido
Menu fixo que esconde o foco reprova a WCAG 2.4.11. Veja onde a barra cobre o link de pular e as âncoras e rode um teste com Puppeteer que pega isso no CI.
Um menu fixo no topo pode reprovar uma página num critério de acessibilidade que entrou na WCAG 2.2: o 2.4.11 Foco não encoberto (mínimo), nível AA. Ele pede que, quando um componente recebe foco do teclado, ele não fique inteiramente escondido por conteúdo criado pelo autor, e cabeçalho fixo é o exemplo clássico. Este post explica o critério, mostra onde a barra costuma esconder o foco e traz um teste com Puppeteer que encontra o problema sozinho e pode rodar no CI.
O CSS do menu em si, a diferença entre sticky e fixed e os motivos de o sticky não grudar estão no par deste post, position: sticky não gruda? Menu fixo no topo em CSS.
O que os critérios 2.4.11 e 2.4.12 pedem
O texto do W3C é curto. No 2.4.11 (AA), o componente com foco precisa ficar pelo menos parcialmente visível. No 2.4.12 (AAA), ele precisa ficar inteiramente visível. Para um site comum, mirar o AA é o mínimo; ter o AAA de graça é um bom sinal de que o espaço da barra foi reservado direito.
Quem depende disso é quem não usa mouse: pessoas que navegam por teclado, por acionador ou por comando de voz precisam ver onde está o foco para saber o que o próximo Enter vai fazer. Se o link focado está atrás da barra, a pessoa aperta Enter às cegas.
O título de uma seção aberta por âncora não é foco, então não entra no 2.4.11. Mas o efeito para quem lê é parecido: a pessoa abre /manual#instalacao e cai num parágrafo solto, sem o título que diz onde ela está. O mesmo CSS resolve os dois casos.
Onde o foco some de verdade
No teste deste post, com Chrome 156, a navegação por Tab no meio da página não escondeu nenhum link: ao focar um link fora da área visível, o Chrome rolou a página e deixou o link longe da borda. O que falhou foram os pontos em que a página para exatamente no topo:
- O link "Pular para o conteúdo". É o primeiro Tab da página e, se não tiver estilo próprio, fica embaixo de uma barra
fixed. - O destino do pular e de qualquer âncora. Ao abrir
#conteudoou#banco, o navegador alinha o alvo no topo da janela, atrás da barra. - Elementos focados por script com
focus()ouscrollIntoView()depois de validar um formulário, que também alinham pelo topo.
Outros navegadores rolam o foco de formas diferentes, e a regra pode mudar entre versões. Por isso o teste precisa rodar no navegador que o seu público usa, e não substituir uma passada manual com Tab e Shift+Tab em Firefox e Safari.
A correção, em resumo
Guarde a altura da barra numa variável e use a mesma variável no scroll-padding-top do html. O navegador passa a descontar essa faixa ao rolar até âncoras, destino de link de pular e, nos navegadores atuais, foco que entra pela borda:
:root { --altura-barra: 64px; }
.topo { position: sticky; top: 0; z-index: 10; min-height: var(--altura-barra); }
html { scroll-padding-top: calc(var(--altura-barra) + 12px); }
.pular { position: absolute; left: 1rem; top: -10rem; }
.pular:focus { top: 1rem; z-index: 20; }
A explicação de cada linha e o caso em que a altura muda no celular estão no post sobre o menu com sticky. Aqui o que interessa é provar que funcionou.
Um teste que acha o foco escondido
O script abaixo usa Puppeteer (puppeteer-core 25.16 com o Chrome instalado no sistema, Node 20). Ele faz duas coisas:
- Aperta Tab e depois Shift+Tab pela página e, a cada passo, mede quanto do elemento focado está atrás de algum elemento
fixedousticky. 100% coberto é falha no 2.4.11; cobertura parcial é aviso para o 2.4.12. - Abre cada âncora linkada na página e mede quanto da primeira linha do alvo ficou atrás da barra.
// foco-coberto.mjs: aperta Tab pela página toda e lista o foco escondido atrás de elementos fixos/sticky
import puppeteer from 'puppeteer-core';
const url = process.argv[2];
const browser = await puppeteer.launch({
executablePath: process.env.CHROME ?? '/usr/bin/google-chrome',
args: ['--no-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
await page.goto(url, { waitUntil: 'load' });
const problemas = [];
const voltas = Number(process.argv[3] ?? 40);
// ida com Tab e volta com Shift+Tab: na volta o navegador rola o foco pela borda de cima
const teclas = [...Array(voltas).fill('Tab'), ...Array(voltas).fill('Shift+Tab')];
for (const tecla of teclas) {
if (tecla === 'Shift+Tab') {
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
} else {
await page.keyboard.press('Tab');
}
const r = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const f = el.getBoundingClientRect();
// elementos fixos ou grudados que podem cobrir o foco
const capas = [...document.querySelectorAll('body *')].filter((c) => {
const p = getComputedStyle(c).position;
return (p === 'fixed' || p === 'sticky') && !c.contains(el);
});
let pior = 0;
for (const c of capas) {
const b = c.getBoundingClientRect();
const w = Math.max(0, Math.min(f.right, b.right) - Math.max(f.left, b.left));
const h = Math.max(0, Math.min(f.bottom, b.bottom) - Math.max(f.top, b.top));
pior = Math.max(pior, (w * h) / (f.width * f.height || 1));
}
return { texto: el.textContent.trim().slice(0, 30), coberto: Math.round(pior * 100) };
});
if (r && r.coberto > 0) problemas.push({ ...r, tecla });
}
// âncoras: abre cada #alvo linkado na página e mede quanto do alvo ficou atrás da barra
const ancoras = await page.evaluate(async () => {
const ids = [...new Set([...document.querySelectorAll('a[href^="#"]')].map((a) => a.hash.slice(1)))];
const res = [];
for (const id of ids) {
const alvo = document.getElementById(id);
if (!alvo) continue;
location.hash = id;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const f = alvo.getBoundingClientRect();
const topo = Math.max(0, ...[...document.querySelectorAll('body *')]
.filter((c) => ['fixed', 'sticky'].includes(getComputedStyle(c).position) && !c.contains(alvo))
.map((c) => c.getBoundingClientRect())
.filter((b) => b.top <= 1)
.map((b) => b.bottom));
// mede só a primeira linha do alvo (um título); 1 linha = até 40 px
const linha = Math.min(f.height, 40);
const coberto = Math.max(0, Math.min(topo, f.top + linha) - f.top) / linha;
if (coberto > 0) res.push({ texto: '#' + id, coberto: Math.round(coberto * 100), tecla: 'âncora' });
}
return res;
});
problemas.push(...ancoras);
await browser.close();
for (const p of problemas) {
const nivel = p.tecla === 'âncora'
? 'título atrás da barra'
: p.coberto === 100 ? 'FALHA 2.4.11 (foco inteiro escondido)' : 'aviso 2.4.12 (foco parcial)';
console.log(`${p.tecla.padEnd(9)} ${String(p.coberto).padStart(3)}% coberto ${nivel} "${p.texto}"`);
}
console.log(problemas.length ? `${problemas.length} problema(s) encontrado(s)` : 'Nenhum foco coberto');
process.exitCode = problemas.some((p) => p.coberto === 100) ? 1 : 0; // falha o CI
Instale com npm i puppeteer-core e rode com node foco-coberto.mjs https://seu-site/manual 40, em que o último número é quantas vezes apertar Tab em cada sentido. Numa página de teste com barra fixed de 64 px e sem scroll-padding-top, a saída foi:
Tab 100% coberto FALHA 2.4.11 (foco inteiro escondido) "Pular para o conteúdo" Shift+Tab 100% coberto FALHA 2.4.11 (foco inteiro escondido) "Pular para o conteúdo" âncora 100% coberto título atrás da barra "#conteudo" âncora 100% coberto título atrás da barra "#instalacao" âncora 100% coberto título atrás da barra "#banco" âncora 100% coberto título atrás da barra "#s1" âncora 100% coberto título atrás da barra "#s3" âncora 100% coberto título atrás da barra "#s5" âncora 100% coberto título atrás da barra "#s6" âncora 100% coberto título atrás da barra "#s7" 10 problema(s) encontrado(s)
A mesma página com sticky, scroll-padding-top e o estilo do link de pular imprimiu Nenhum foco coberto e saiu com código 0. A figura mostra as duas versões abertas no mesmo link #instalacao:

Como usar no CI
O script sai com código 1 quando algum foco fica 100% coberto, então basta rodá-lo como passo do pipeline contra o ambiente de homologação. Três cuidados para o teste não virar ruído:
- Rode em duas larguras. Troque o
setViewportpara 390 x 844 numa segunda passada. É no celular que a barra quebra em duas linhas e a variável de altura fica mentindo. - Ignore o que a pessoa abriu. Pela nota do próprio critério, conteúdo que a pessoa abriu, como um menu suspenso, não conta como encobrimento se ela puder revelar o item focado sem mover o foco (fechando o menu com Esc, por exemplo). Feche menus antes de medir.
- Banner de cookies conta. Um aviso fixo no rodapé é conteúdo do autor e esconde o foco que entra por baixo. Teste com e sem o banner aceito.
O teste não substitui o olhar humano para o anel de foco em si: se ele quase não aparece no tema escuro, o problema é outro critério, tratado em foco visível no tema escuro. E quando um modal fecha, o foco precisa voltar para o botão que o abriu, como em foco que volta.
Resumo
O 2.4.11 reprova foco inteiramente escondido por barra fixa; o 2.4.12 pede o foco inteiro visível. Os pontos frágeis são o link de pular, as âncoras e o foco mandado por script. Reserve o espaço com scroll-padding-top ligado à altura real da barra e deixe um teste automatizado conferindo isso a cada deploy.