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:
- O modal tem semântica (preferência por <dialog> ou role="dialog" + aria-modal).
- Ao abrir, foco vai para o primeiro controle importante dentro do modal.
- O foco fica preso dentro do modal enquanto ele estiver aberto (trap de foco).
- Ao fechar, o foco retorna ao elemento que abriu o modal.
- Conteúdo de fundo é marcado como inacessível para leitores de tela (aria-hidden ou inert).
- 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
Skip links e landmarks: navegação por teclado acessível
Aprenda, passo a passo, a implementar skip links e landmarks para tornar páginas HTML navegáveis por teclado. Técnicas simples, exemplos funcionais e…
Validação de formulários acessível com ARIA: role=alert e live regions
Como tornar validação de formulários acessível usando role=alert e live regions: exemplos práticos, padrões simples, foco no primeiro erro e armadilhas a…
Breadcrumb semântico e acessível com Web Component + JSON-LD — passo a passo
Crie um breadcrumb acessível e semântico usando Web Component que renderiza nav/ol, marca aria-current e injeta JSON-LD sincronizado para SEO. Passo a passo…