Menu hambúrguer que abre, fecha no Esc e devolve o foco
Monte o menu retrátil com um botão que diz se está aberto, feche no Esc e no clique fora, devolva o foco ao botão e mostre o menu inteiro na tela larga.
No celular, a pessoa toca no ícone de três linhas, a lista de links desce por cima do conteúdo e ela desiste de navegar. Procura um "X", não acha, toca no ícone de novo e nada muda porque o script só sabia abrir. Quem usa teclado tem um problema pior: o menu fechou visualmente, mas o Tab continua passando pelos links invisíveis antes de chegar ao conteúdo.
A resposta curta: o menu retrátil é um botão com estado e uma lista que aparece e some de verdade. O botão tem nome e aria-expanded; a lista usa o atributo hidden quando está fechada; Esc fecha e devolve o foco ao botão; clicar fora fecha também. Na tela larga, se há espaço, o menu fica visível e o botão some.
Por que o menu "só visual" prende a navegação
A maioria dos menus retráteis antigos anima a altura ou a posição da lista com CSS ou jQuery (slideToggle, left: -300px). A lista sai do campo de visão, mas continua no documento. Para o leitor de tela e para o Tab, ela está lá: links que ninguém enxerga, recebendo foco, na frente do conteúdo.
O segundo problema é o estado. Se o botão não diz se o menu está aberto, quem não enxerga a animação não sabe o que o toque fez. O atributo aria-expanded no botão resolve isso: o leitor de tela anuncia "Menu, botão, recolhido" ou "expandido", e você ganha de brinde um seletor para estilizar o ícone.
O terceiro é a saída. Um painel que abre por cima do conteúdo precisa de pelo menos dois jeitos de fechar: o próprio botão e o Esc. Clicar fora é um terceiro jeito, bem-vindo, mas não pode ser o único, porque no teclado não existe "fora".
Como montar o menu passo a passo
- Use um
<button type="button">com texto visível ou nome acessível ("Menu"), nunca um<div>com ícone. - Ligue o botão à lista com
aria-controlse comece comaria-expanded="false". - Deixe a lista visível no HTML e esconda pelo JavaScript. Se o script falhar, o menu continua usável.
- Ao abrir, tire o
hiddenda lista e mudearia-expandedparatrue. Ao fechar, o contrário. - No Esc, feche e mova o foco de volta para o botão.
- No clique fora do menu e do botão, feche sem mexer no foco.
- Na tela larga, mostre a lista sempre e esconda o botão.
O HTML
<header>
<a href="/">Loja Exemplo</a>
<button type="button" aria-expanded="false" aria-controls="menu-principal" data-menu-botao>
Menu
</button>
<nav aria-label="Principal">
<ul id="menu-principal">
<li><a href="/produtos">Produtos</a></li>
<li><a href="/ofertas">Ofertas</a></li>
<li><a href="/atendimento">Atendimento</a></li>
</ul>
</nav>
</header>
Os itens são links comuns, dentro de um <nav> com rótulo. Não use role="menu" aqui: esse papel é para menus de aplicativo, como os de um editor de texto, e promete ao leitor de tela navegação por setas que você não implementou. Navegação de site é lista de links.
O JavaScript
const botao = document.querySelector('[data-menu-botao]');
const menu = document.getElementById(botao.getAttribute('aria-controls'));
const telaLarga = window.matchMedia('(min-width: 900px)');
function abrir() {
menu.hidden = false;
botao.setAttribute('aria-expanded', 'true');
}
function fechar({ devolverFoco = false } = {}) {
menu.hidden = true;
botao.setAttribute('aria-expanded', 'false');
if (devolverFoco) botao.focus();
}
botao.addEventListener('click', () => {
if (menu.hidden) abrir();
else fechar();
});
document.addEventListener('keydown', (evento) => {
if (evento.key === 'Escape' && !menu.hidden && !telaLarga.matches) {
fechar({ devolverFoco: true });
}
});
document.addEventListener('click', (evento) => {
if (menu.hidden || telaLarga.matches) return;
if (menu.contains(evento.target) || botao.contains(evento.target)) return;
fechar();
});
function ajustarAoTamanho() {
if (telaLarga.matches) {
menu.hidden = false;
} else {
fechar();
}
}
telaLarga.addEventListener('change', ajustarAoTamanho);
ajustarAoTamanho();
A chamada final de ajustarAoTamanho é o que esconde a lista no celular quando o script carrega. Antes disso, ela está visível, então quem fica sem JavaScript por qualquer motivo ainda vê os links.
O matchMedia com o evento change evita escutar resize a cada pixel. Quando a pessoa gira o tablet e passa de 900 pixels, o menu aparece inteiro; quando volta, ele fecha. Use o mesmo número no CSS:
@media (min-width: 900px) {
[data-menu-botao] { display: none; }
#menu-principal { display: flex; gap: 1.5rem; }
}
Detalhes que separam o menu bom do que só funciona
Devolver o foco só quando faz sentido
No Esc, o foco volta ao botão, porque a pessoa estava dentro do menu e precisa de um ponto de partida. No clique fora, não: ela clicou em outro lugar de propósito, e puxar o foco de volta para o topo da página atrapalharia. Quando a pessoa escolhe um link, a página muda e o foco segue a navegação normal.
Menu que cobre a tela inteira vira modal
Se o seu menu ocupa a tela toda, com fundo escuro e o conteúdo atrás inacessível, ele deixou de ser um painel retrátil e virou um diálogo. Nesse caso, o foco precisa ficar preso dentro dele enquanto estiver aberto, e o elemento <dialog> com showModal() resolve boa parte disso. O cuidado com o retorno do foco está em modal que devolve o foco. Para um menu que desce e empurra ou cobre só parte do conteúdo, não prenda o foco.
Animação sem esconder o estado
Animar a abertura é possível, mas a animação não pode substituir o hidden. Se quiser transição, anime a altura ou a opacidade e aplique o hidden ao fim da animação de fechamento. Respeite prefers-reduced-motion para quem desligou animações no sistema.
Menu fixo no topo
Se o cabeçalho com o botão fica fixo durante a rolagem, ele não pode cobrir o título da seção nem o elemento com foco. As medidas para isso estão em menu fixo que não cobre o título, e o comportamento de acompanhar a rolagem em menu que acompanha a rolagem.
Erros comuns
- Esconder a lista só com posição ou altura. Os links continuam no Tab e no leitor de tela, invisíveis, na frente do conteúdo.
- Botão sem
aria-expanded. Quem não vê a animação não sabe se o toque abriu ou fechou o menu. - Ícone de três linhas sem nome. O leitor de tela anuncia "botão" e nada mais.
- Clicar fora como única saída. No teclado não existe "fora", e o menu fica aberto até a pessoa desistir.
- Esc que fecha e deixa o foco num link escondido. O próximo Tab some em lugar nenhum e a pessoa perde a posição.
role="menu"numa lista de links. O leitor de tela espera setas e a navegação real não corresponde ao anunciado.
Checklist
- O controle é um
<button>com nome acessível. aria-expandedno botão muda junto com a lista.- A lista fechada tem
hiddene sai da ordem do Tab. - Esc fecha o menu e devolve o foco ao botão.
- Clicar fora fecha, sem roubar o foco.
- Sem JavaScript, os links continuam visíveis.
- Na tela larga, a lista aparece inteira e o botão some.
Abra o menu do seu site no celular ou com a janela estreita, aperte Tab até entrar nele e depois Esc. O foco voltou para o botão que abriu, ou ficou perdido num link que você não consegue mais ver?
Referência: http://blog.thiagobelem.net/criando-um-menu-retratil-com-css-e-jquery/