DEV Community

Cover image for Como criar um menu hambúrguer acessível com a "armadilha de foco" (Focus Trap)
Carolina Gonçalves
Carolina Gonçalves

Posted on

Como criar um menu hambúrguer acessível com a "armadilha de foco" (Focus Trap)

Olá, pessoal!

Vamos direto ao ponto: seu menu hambúrguer provavelmente está quebrado para usuários de leitores de tela.

Eu descobri isso da pior maneira. Em 2022, enquanto desenvolvia o site de um evento sobre acessibilidade digital, o designer responsável me fez a seguinte pergunta:

“Você já testou o layout com leitor de tela? O evento é sobre acessibilidade, precisamos dar o exemplo...”

Fui testar no mobile e encontrei o problema: o leitor de tela lia todos os links de um menu visualmente fechado. A pessoa ouvia “Contato, link”, pressionava o "Enter" e nada acontecia. Era um link fantasma.

Tomei aquele "puxão de orelha" e aprendi uma lição: a experiência do usuário tem que ser a mesma para todos, e esconder um menu só com CSS não resolve o problema. Era preciso gerenciar o estado (aberto/fechado) e, principalmente, o foco do teclado.

E a solução que encontrei foi implementar a "Focus Trap" (armadilha de foco) usando JavaScript e atributos ARIA. Vou te mostrar o passo a passo:

(Para quem já quiser ver na prática, acesse o resultado final e o projeto completo no CodeSandbox.)


A estrutura HTML do menu

Tudo começa com um HTML semântico dividido em duas partes: o botão que dispara a ação e o painel que guarda os links.

1. O botão de ativação

É onde o usuário clica para abrir o menu. E a dúvida mais comum nessa parte é: por que o leitor de tela precisa ouvir “botão” e não “link”?

Pense assim:

  • Um link é como uma porta: leva o usuário pra outro lugar.
  • Um botão é como um interruptor: executa uma ação onde ele já está.

Se o leitor de tela anuncia “Menu, link”, a experiência fica confusa. Por isso usamos role="button", que comunica ao leitor de tela que o elemento executa uma ação local.

Agora, talvez você se pergunte: por que usar <a> e não <button> de vez?

A resposta é simples: a tag <a> com href serve como plano B. Se o JavaScript falhar (erro, conexão ruim, bloqueio por extensão), o href ainda leva o usuário até a seção do menu. Um <button> puro não funcionaria.

<a 
  id="pageHeaderHamburgerIcon"
  href="#pageContainerMainNavMobile"
  role="button"
  aria-controls="navigation"
  aria-expanded="false"
  aria-label="Abrir menu de navegação"
  title="Abrir menu de navegação"><span class="sr-only">Abrir menu</span>
</a>
Enter fullscreen mode Exit fullscreen mode

Resumindo a função de cada um:

  • role="button": Ajusta a expectativa do leitor de tela, anunciando “botão”.
  • href: Garante o fallback se o JavaScript falhar.
  • id: Identifica o botão para que o JavaScript possa manipulá-lo.
  • aria-controls: Conecta o botão ao menu que ele controla.
  • aria-expanded: Indica o estado atual, false (fechado) ou true (aberto).
  • aria-label e title: Garantem que a função do botão seja sempre clara. Quando o menu está aberto, o JavaScript atualiza o texto para "Fechar menu", e vice-versa, mantendo o usuário sempre informado sobre a próxima ação.

2. O painel de navegação

É o bloco que guarda os links e que aparece e desaparece.

<nav id="pageContainerMainNavMobile">
  <h2 class="sr-only" id="mainNavigationLabelMobile">Menu Principal</h2>
  <ul id="mainNavigationMobile">
    <li><a href="#" title="Saiba mais sobre o festival">Sobre</a></li>
    <li><a href="#" title="Veja quem realiza o evento">Realização</a></li>
    <li><a href="#" title="Entre em contato conosco">Contato</a></li>
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode

O essencial aqui:

  • <nav> com id: A tag <nav> é a correta para um bloco de links principal. O id é o "alvo" do aria-controls do botão.
  • <h2 class="sr-only">: Este título invisível é uma prática recomendada, pois o leitor de tela o utiliza para dizer ao usuário onde ele está antes de listar as opções de navegação.

Onde a mágica acontece: o JavaScript

O JavaScript é o nosso mestre da bateria: ele dá o ritmo, controla o abre/fecha, atualiza os atributos ARIA e, o mais importante, gerencia o foco do teclado.

Vamos ver a lógica passo a passo:

1. Preparando o terreno

Antes de qualquer ação, nosso script precisa "conhecer" os elementos do HTML. Por isso, começamos selecionando o botão, o menu e o último link em variáveis para usarmos depois.

document.addEventListener('DOMContentLoaded', function() {
  const keys = {
    tab: 9,
    esc: 27,
  };

  const menuButton = document.getElementById('pageHeaderHamburgerIcon');
  const navMenu = document.getElementById('pageContainerMainNavMobile');
  const menuLinks = navMenu.querySelectorAll('a');
  const lastLink = menuLinks[menuLinks.length - 1];

  document.body.classList.add('js');
Enter fullscreen mode Exit fullscreen mode

2. As funções openMenu e closeMenu

Estas são as duas funções que controlam o estado do menu, atualizando os atributos e controlando a visibilidade.

  const openMenu = () => {
    // Atualiza os atributos para o estado "Aberto"
    menuButton.setAttribute('aria-expanded', 'true');
    menuButton.setAttribute('aria-label', 'Fechar menu de navegação');
    menuButton.setAttribute('title', 'Fechar menu de navegação');

    // Troca o ícone e mostra o menu
    menuButton.innerHTML = '\u00D7<span class="sr-only">Fechar menu</span>';
    navMenu.style.display = 'block';
  };

  const closeMenu = () => {
    // Atualiza os atributos para o estado "Fechado"
    menuButton.setAttribute('aria-expanded', 'false');
    menuButton.setAttribute('aria-label', 'Abrir menu de navegação');
    menuButton.setAttribute('title', 'Abrir menu de navegação');

    // Troca o ícone e esconde o menu
    menuButton.innerHTML = '\u2630<span class="sr-only">Abrir menu</span>';
    navMenu.style.display = 'none';
  };
Enter fullscreen mode Exit fullscreen mode

3. O gatilho e a armadilha de foco

Agora, conectamos as funções ao clique e adicionamos a lógica do teclado para controlar o foco.

  // Gatilho do clique no botão
  menuButton.addEventListener('click', (e) => {
    e.preventDefault();
    const isExpanded = menuButton.getAttribute('aria-expanded') === 'true';
    isExpanded ? closeMenu() : openMenu();
  });

  // Tecla ESC fecha o menu
  navMenu.querySelectorAll('*').forEach(el => {
    el.addEventListener('keydown', (e) => {
      if (e.keyCode === keys.esc) {
        closeMenu();
        menuButton.focus();
      }
    });
  });

  // Tab no último item volta para o botão (o loop do foco)
  lastLink.addEventListener('keydown', (e) => {
    if (e.keyCode === keys.tab && !e.shiftKey) { 
      e.preventDefault();
      menuButton.focus();
    }
  });

  // Shift+Tab no botão (com menu aberto) vai para o último item
  menuButton.addEventListener('keydown', (e) => {
    const isExpanded = menuButton.getAttribute('aria-expanded') === 'true';
    if (isExpanded && e.keyCode === keys.tab && e.shiftKey) {
      e.preventDefault();
      lastLink.focus();
    }
  });
}); 
Enter fullscreen mode Exit fullscreen mode

Resultado

O menu agora é funcional para todos, visível e navegável por teclado. Teste com leitores de tela (NVDA, VoiceOver) e navegação por teclado (Tab, Shift+Tab, Esc).

GIF animado mostrando a navegação por teclado em um menu mobile. O foco do teclado circula entre o botão de menu e os links internos, sem escapar para o conteúdo da página, demonstrando a 'armadilha de foco'. Demonstração do menu acessível com a 'Armadilha de Foco' em funcionamento.


Conclusão

Uma coisa que eu sempre falo: só podemos dizer que um site seguiu as boas práticas quando ele é acessível ao maior número de pessoas possível.

Da próxima vez que você for codificar um menu, um modal ou um simples botão, faça aquela pergunta: "Eu testei com leitor de tela?".

A diferença na experiência do usuário é enorme.

Top comments (0)