Destino Dev

Blog

Modal acessível com <dialog>: semântica, foco e fallback passo a passo

Por John Pires · 19 de março de 2026

Introducao ao problema

A construção de modais (dialogs) é um dos pontos que mais causa confusão para quem está começando: visualmente é fácil — basta posicionar uma caixa sobre a página — mas semanticamente e em termos de acessibilidade não é. Usuários que usam leitor de tela, navegam apenas por teclado ou têm dispositivos com navegadores antigos podem ficar presos ou perder o contexto se o modal não for tratado corretamente.

O elemento <dialog> traz semântica nativa ao modal, mas seu uso também exige cuidados: foco, retorno do foco, teclas de atalho, marcação ARIA como complemento e um fallback para navegadores que não implementam a API. Este artigo investiga o problema, compara alternativas e entrega um passo a passo para criar um modal acessível que funcione em todos os cenários.

O problema em termos práticos

Imagine um formulário em um modal. Um usuário abre o modal, digita, fecha. Para um experiente em acessibilidade, há uma checklist mental: foco inicial, trancar o foco dentro do modal, bloquear o conteúdo de fundo para leitores de tela, permitir ESC para fechar, devolver foco ao elemento que abriu o modal. Para iniciantes, cada item pode ser um quebra-cabeça separado.

Por que não usar apenas um elemento "div"?

Um simples <div> com posicionamento absoluto pode parecer suficiente, mas ele não transmite a semântica de diálogo para tecnologias assistivas. Sem semântica e sem gerenciamento de foco, navegadores e leitores de tela não tratam o conteúdo como um contexto modal isolado — o que quebra a experiência de navegação.

Tip: usar semântica correta reduz a quantidade de JS necessário. <dialog> resolve parte do problema, mas não tudo.

Pre-requisitos e setup

Conhecimentos mínimos necessários

Você precisa saber HTML5 básico, um pouco de CSS (posicionamento e z-index), e JavaScript para manipular o DOM, eventos de teclado e gerenciamento de foco. Nada avançado — mas é necessário entender o fluxo de eventos do navegador e como selecionar elementos com querySelector.

Ambiente de teste recomendado

Teste em pelo menos dois navegadores: um moderno com suporte a <dialog> (Chrome, Edge, Firefox recente) e um sem suporte (versões antigas do Safari ou navegadores móveis antigos). Use também uma ferramenta de leitor de tela (NVDA, VoiceOver) e teste só com teclado (tab, shift+tab, esc) para validar o comportamento.

Tutorial guiado passo a passo

1) Estrutura HTML básica com <dialog>

Vamos primeiro montar um modal mínimo usando <dialog>. Este exemplo demonstra a marcação semântica e os atributos básicos. Ele serve como base para adicionar foco e comportamentos.

<!doctype html>
<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Exemplo: dialog nativo</title>
  <style>
    .overlay { padding: 0; }
    dialog { padding: 1rem; border-radius: 6px; border: 1px solid #ccc; }
  </style>
</head>
<body>
  <button id="openBtn">Abrir modal</button>
  <dialog id="modal" aria-labelledby="modalTitle">
    <h2 id="modalTitle">Modal nativo</h2>
    <p>Conteúdo do modal usando <dialog>.</p>
    <button id="closeBtn">Fechar</button>
  </dialog>
  <script>
    const openBtn = document.getElementById('openBtn');
    const modal = document.getElementById('modal');
    const closeBtn = document.getElementById('closeBtn');

    openBtn.addEventListener('click', () => {
      if (typeof modal.showModal === 'function') {
        modal.showModal();
      } else {
        modal.setAttribute('open', ''); // fallback visual
      }
      document.getElementById('closeBtn').focus();
    });

    closeBtn.addEventListener('click', () => modal.close());

    // Escuta tecla ESC para fechar
    modal.addEventListener('cancel', (e) => modal.close());
  </script>
</body>
</html>

O que faz: cria um botão que abre um <dialog> com showModal(). Se o método não existir, aplica o atributo open como fallback visual. Depois de abrir, foca o botão de fechar.

O que acontece: em navegadores com suporte, o diálogo modal se comporta como modal nativo (bloqueia interação no fundo). Em navegadores sem API, apenas exibimos a caixa — ainda precisamos de um fallback completo para foco e leitores de tela.

2) Gerenciando foco e retorno do foco (acessibilidade)

É crucial devolver o foco para o elemento que abriu o modal e impedir que o usuário tabule para elementos fora do modal. O navegador nativo já bloqueia o foco, mas precisamos implementar ainda o retorno do foco e lidar com teclas como ESC quando usamos fallback.

<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Foco e retorno</title></head>
<body>
  <button id="openBtn">Abrir modal</button>
  <dialog id="modal" aria-labelledby="modalTitle">
    <h2 id="modalTitle">Formulário de contato</h2>
    <form method="dialog">
      <label>Nome: <input type="text" name="nome"></label><br>
      <label>E-mail: <input type="email" name="email"></label><br>
      <menu>
        <button id="submitBtn" type="submit">Enviar</button>
        <button id="closeBtn" type="button">Cancelar</button>
      </menu>
    </form>
  </dialog>
  <script>
    const openBtn = document.getElementById('openBtn');
    const modal = document.getElementById('modal');
    const closeBtn = document.getElementById('closeBtn');
    let triggerElement = null;

    openBtn.addEventListener('click', () => {
      triggerElement = document.activeElement; // guarda o que abriu
      if (typeof modal.showModal === 'function') {
        modal.showModal();
      } else {
        modal.setAttribute('open', '');
      }
      // foco inicial dentro do modal
      modal.querySelector('input, button, [tabindex]')?.focus();
    });

    closeBtn.addEventListener('click', () => {
      modal.close();
      if (triggerElement) triggerElement.focus();
    });

    modal.addEventListener('close', () => {
      if (triggerElement) triggerElement.focus();
    });
  </script>
</body>
</html>

O que faz: guarda o elemento que abriu o modal, define um foco inicial dentro do modal e garante que ao fechar o foco retorna ao disparador original.

O que acontece: navegação por teclado fica previsível — quem abriu volta a ter o foco, e o usuário não perde contexto.

3) Fallback para navegadores sem <dialog> e trap de foco manual

Se o navegador não suporta a API, precisamos simular comportamento modal: mostrar um elemento sobreposto, esconder o conteúdo de fundo para leitores de tela (aria-hidden/inert) e implementar um trap de foco que impede a navegação para fora do modal.

<!doctype html>
<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Fallback dialog</title>
  <style>
    .sr-only { position: absolute; left: -9999px; }
    .modal-backdrop {
      position: fixed; inset: 0; background: rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:1000;
    }
    .modal { background: #fff; padding: 1rem; width: 90%; max-width: 500px; border-radius: 6px; }
    [data-hidden="true"] { pointer-events: none; user-select: none; }
  </style>
</head>
<body>
  <main id="page">
    <button id="openBtn">Abrir modal (fallback)</button>
    <p>Conteúdo da página que deve ficar inacessível enquanto o modal estiver aberto.</p>
  </main>

  <div id="fallbackModal" class="modal-backdrop" hidden>
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="title">
      <h2 id="title">Modal (fallback)</h2>
      <button id="closeBtn">Fechar</button>
    </div>
  </div>

  <script>
    const openBtn = document.getElementById('openBtn');
    const fallback = document.getElementById('fallbackModal');
    const closeBtn = document.getElementById('closeBtn');
    const page = document.getElementById('page');
    let trigger = null;

    function trapFocus(container) {
      const focusable = container.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])');
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      function handle(e) {
        if (e.key === 'Tab') {
          if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
          } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
          }
        } else if (e.key === 'Escape') {
          closeModal();
        }
      }

      container.addEventListener('keydown', handle);
      return () => container.removeEventListener('keydown', handle);
    }

    let releaseTrap = null;

    function openModal() {
      trigger = document.activeElement;
      page.setAttribute('aria-hidden', 'true');
      page.setAttribute('data-hidden', 'true');
      fallback.hidden = false;
      fallback.querySelector('.modal').focus();
      releaseTrap = trapFocus(fallback);
    }

    function closeModal() {
      fallback.hidden = true;
      page.removeAttribute('aria-hidden');
      page.removeAttribute('data-hidden');
      if (releaseTrap) releaseTrap();
      if (trigger) trigger.focus();
    }

    openBtn.addEventListener('click', openModal);
    closeBtn.addEventListener('click', closeModal);
  </script>
</body>
</html>

O que faz: exibe um elemento com role="dialog" quando não há suporte, marca o conteúdo de fundo com aria-hidden e aplica um trap de foco manual com lógica de tabulação circular.

O que acontece: usuários de teclado ficarão presos dentro do modal até fechá-lo; leitores de tela encontrarão o modal como contexto único por causa de aria-modal e aria-hidden no fundo.

Erros comuns e armadilhas

Muitos desenvolvedores cometem erros simples que quebram a experiência. Aqui estão os mais frequentes e como evitá-los.

Falta de retorno do foco

Erro: abrir o modal e não voltar o foco ao elemento disparador. Resultado: usuário perde o ponto de navegação e precisa procurar visualmente onde estava. Correção: guarde document.activeElement antes de abrir e chame focus() ao fechar.

Não bloquear o conteúdo de fundo

Erro: permitir que o teclado navegue até elementos fora do modal. Isso causa confusão: o usuário pode interagir com botões do fundo sem perceber que o modal está aberto. Correção: use a API nativa que trata isso ou implemente aria-hidden/inert e um trap de foco manual.

Dica: o atributo inert (quando disponível) é a forma ideal de desacoplar o fundo — ele previne tabulação e eventos. Mas ainda não é suportado em todos os navegadores.

Outras armadilhas: esquecer de tratar ESC em fallback, não usar aria-modal ou aria-labelledby, e não testar com leitores de tela. Qualquer ausência dessas práticas prejudica usuários reais.

Resultado final funcionando

Checklist de aceitação

Ao terminar, valide estes pontos:

  1. O modal tem semântica (preferência por <dialog> ou role="dialog" + aria-modal).
  2. Ao abrir, foco vai para o primeiro controle importante dentro do modal.
  3. O foco fica preso dentro do modal enquanto ele estiver aberto (trap de foco).
  4. Ao fechar, o foco retorna ao elemento que abriu o modal.
  5. Conteúdo de fundo é marcado como inacessível para leitores de tela (aria-hidden ou inert).
  6. Tecla ESC fecha o modal tanto em fallback quanto em browsers com API.

Testes práticos

Faça estes testes manuais:

  • Navegue apenas com teclado: Tab/Shift+Tab, abra/feche o modal e verifique se não sai do contexto.
  • Use o leitor de tela e confirme se o título e os controles do modal são anunciados.
  • Teste em um browser que não suporte <dialog> e confirme o fallback.

Se todos os pontos acima estiverem corretos, você tem um modal acessível e robusto.

Desafio para o leitor praticar

Desafio 1: Formulário real dentro do modal

Implemente um modal que contenha um formulário com validação e envio via fetch. Garanta que erros de validação sejam anunciados para leitores de tela (use aria-live ou aria-describedby apontando para mensagens de erro).

Desafio 2: Componentizar o modal

Crie um Web Component que encapsule o comportamento: utilização de <slot> para conteúdo, atributos para abrir/fechar e eventos personalizados (open, close). O componente deve usar <dialog> quando disponível e fallback quando não.

Esses desafios obrigam você a consolidar semântica, gerenciamento de foco e fallback — as três peças essenciais.

Conclusao pratica

O elemento <dialog> resolve grande parte do trabalho semântico, mas não elimina a necessidade de gerenciar o foco, o retorno do foco e de prover um fallback para navegadores sem suporte. A combinação de semântica correta, manipulação de foco e marcação ARIA garante que seu modal seja realmente acessível.

Implementar um modal acessível é um exercício de atenção aos detalhes: cada técnica (showModal, aria-hidden, trap de foco, retorno de foco) desempenha um papel. Teste em múltiplos ambientes, priorize o comportamento no teclado e lembre-se: acessibilidade não é um extra — é parte fundamental da funcionalidade.

Próximo passo: pegue os códigos deste artigo, crie uma pequena página de testes e execute os desafios propostos. Depois, valide com uma pessoa que usa leitores de tela ou com ferramentas automatizadas — isso revela problemas que testes manuais não mostram.

Se precisar, posso gerar um Web Component pronto com fallback e documentação passo a passo — peça para eu criar o componente e eu retorno com código completo e testado.

Assuntos: HTML, acessibilidade, dialog

Leia também