Construindo Modais Acessíveis: `<dialog>` Nativo ou uma Biblioteca?
<dialog> nativo ou biblioteca para modais acessíveis: foco, fundo inert, tecla ESC, bloqueio de rolagem e quando focus traps ainda importam.
Em 2026, adote como padrão o elemento nativo <dialog> com showModal(): ele oferece contenção de foco, fechamento via ESC, renderização em top layer, prevenção de rolagem do fundo e um aria-modal implícito — tudo sem nenhum JavaScript adicional. Recorra a bibliotecas como Radix, React Aria, Headless UI ou a11y-dialog somente quando você se deparar com empilhamento complexo, requisitos de animação elaborados, composabilidade em design systems, ou uma limitação nativa que você tenha efetivamente medido.
Esse veredicto inverte anos de sabedoria convencional. O conselho padrão — usar portal para o modal, capturar o foco manualmente, ocultar o fundo com aria-hidden, configurar o ESC à mão — descrevia o que você precisava fazer antes de <dialog> e inert serem implementados. O elemento <dialog> é Baseline em todos os principais navegadores desde março de 2022, portanto a maior parte desse scaffolding agora é comportamento padrão do navegador. Este artigo resolve a escolha entre nativo e biblioteca com critérios concretos, aborda corretamente as nuances do focus trap onde a maioria dos guias repete conselhos desatualizados, apresenta uma implementação mínima verificada em vanilla JS e React, e encerra com a realidade dos leitores de tela — porque o ARIA correto, por si só, não resolve tudo.
Principais Conclusões
- O
<dialog>nativo aberto comshowModal()posiciona o diálogo no top layer do navegador e torna o restante da páginainert, de modo que o foco fica contido no modal sem nenhum focus trap em JavaScript. - Não adicione um focus trap em JavaScript a um
<dialog>nativo: a única coisa que o Tab ainda pode alcançar é o próprio chrome do navegador, e usuários de teclado devem poder acessá-lo exatamente como usuários de mouse podem. - Focus traps manuais pertencem a modais customizados que não usam
<dialog>— e se você construir um, você é responsável pelo atributoinertno fundo, pois nada o gerencia por você. - O problema nativo mais comum é silencioso: se o primeiro controle focalizável estiver no final do DOM do diálogo,
showModal()o foca e o modal abre rolado até o final — corrija isso posicionando o botão de fechar após o cabeçalho no código-fonte. - O ARIA correto é necessário, mas não suficiente: em 2026, Safari com VoiceOver ainda pode deixar conteúdo estático dentro de um diálogo
aria-modalinacessível, portanto teste em leitores de tela reais.
O que um modal acessível realmente precisa fazer
Um modal acessível tem cinco comportamentos inegociáveis: mover o foco para dentro de si ao abrir, tornar o fundo inacessível e não interativo, permitir fechamento via teclado (ESC) e um controle visível, restaurar o foco para o elemento que o acionou ao fechar, e expor um papel de diálogo com um nome acessível para que leitores de tela o anunciem. Esses comportamentos mapeiam diretamente para critérios de sucesso da WCAG — ordem de foco (2.4.3), operabilidade por teclado (2.1.1, 2.1.2) e nome/papel/valor (4.1.2). A referência rápida completa da WCAG 2.2 lista os demais; esse conjunto reduzido é o que define o sucesso ou fracasso de um modal.
O motivo pelo qual esses requisitos costumam ser mal implementados é que cada um deles é uma peça separada de maquinaria em um modal position: fixed construído manualmente. Você programa a movimentação do foco, a restauração do foco, o listener do ESC, a ocultação do fundo e a configuração do papel de forma independente — e qualquer uma delas regredindo silenciosamente produz um modal que parece funcionar para um usuário de mouse e está quebrado para todos os demais. O argumento a favor do elemento nativo é que ele colapsa a maior parte dessa maquinaria em uma única chamada ao DOM.
O que <dialog> e showModal() oferecem gratuitamente — e o que não oferecem
Discover how at OpenReplay.com.
Chamar showModal() em um <dialog> oferece, sem nenhum JavaScript além da própria chamada: foco inicial movido para dentro do diálogo, fechamento via tecla Esc gerenciado pelo navegador, renderização em top layer que contorna z-index e a complexidade de portais, um backdrop estilizável com ::backdrop, um role="dialog" implícito com aria-modal="true", e um fundo inert para que o Tab não alcance o conteúdo da página por trás. O método close() então restaura o foco para o elemento que abriu o diálogo. Jared Cunha, que reconstruiu o modal do U.S. Web Design System sobre <dialog>, relata que a reescrita passou de aproximadamente 400 linhas de JavaScript mais quatro utilitários para cerca de 38 linhas — os comportamentos de acessibilidade que você costumava programar agora são padrões do navegador.
O que ele não oferece, e as correções:
| Problema | O que acontece | Correção |
|---|---|---|
| Primeiro elemento focalizável no final do DOM | showModal() o foca e o diálogo abre rolado até o final | Coloque o botão de fechar após o cabeçalho na ordem do código-fonte; ou defina o foco inicial deliberadamente |
Atributo autofocus | Não é confiável para mover o foco inicial em alguns navegadores desktop | Defina o foco via JS após showModal(), apontando para o elemento correto |
| Clique no backdrop | O diálogo nativo não fecha ao clicar fora por padrão | Escute cliques onde event.target === dialogEl |
| Rolagem do fundo | A página por trás do diálogo ainda pode ser rolada | Bloqueie a rolagem explicitamente (veja abaixo) |
O comportamento de abertura rolada até o final não é uma peculiaridade de uma biblioteca específica — o exemplo de diálogo modal do W3C ARIA APG documenta que posicionar o foco inicial em um elemento próximo ao final do conteúdo do diálogo pode fazer com que ele abra fora da área visível. A correção mais limpa é a ordem do código-fonte: cabeçalho primeiro, botão de fechar depois, para que o leitor de tela anuncie o título antes do controle e o diálogo abra no topo.
Para bloquear a rolagem, capture a posição de rolagem, bloqueie o body e restaure ao fechar:
:root:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable; /* evita o salto de layout quando a barra de rolagem desaparece */
}
scrollbar-gutter: stable reserva a largura da barra de rolagem para que a página não se desloque lateralmente quando a rolagem é bloqueada. No iOS Safari, overflow: hidden no body é notoriamente falho; capturar window.scrollY e aplicar position: fixed com um top negativo é a abordagem mais confiável entre plataformas quando necessário.
A questão do focus trap, resolvida: não capture o foco em um diálogo nativo
Não adicione um focus trap em JavaScript a um <dialog> nativo aberto com showModal(). O navegador já o posiciona no top layer e torna o restante da página inert, portanto o foco está contido — a única coisa que o Tab ainda pode alcançar é o próprio chrome do navegador (barra de endereços, abas, extensões), que usuários de teclado devem poder acessar, exatamente como usuários de mouse podem. Este é o conselho desatualizado mais repetido em tutoriais sobre modais, e está errado para o elemento nativo.
O raciocínio vem de autoridades reconhecidas em acessibilidade, sintetizado por Zell Liew no artigo “There is No Need to Trap Focus on a Dialog Element” do CSS-Tricks. A posição de Scott O’Hara é que a WCAG não exige normativamente o aprisionamento de foco; a recomendação de captura surgiu de orientações informativas escritas antes de inert e <dialog> serem amplamente suportados, quando diálogos programados tornavam a captura mais fácil do que tornar elementos externos não tabuláveis. O enquadramento de Léonie Watson é que os usuários devem manter as mesmas opções de controle dentro de um diálogo que em uma página normal — eles ainda devem poder alcançar os controles do navegador. A visão do W3C APA Working Group é que o comportamento nativo do diálogo deve permanecer como está, em parte porque tabular até o chrome do navegador é um mecanismo de escape em contextos de quiosque e ambientes restritos.
Este é um debate genuíno e atual, não uma lei estabelecida. Jared Cunha, no trabalho do USWDS citado acima, ainda adiciona uma função de focus trap no elemento nativo. Seu raciocínio é cautela prática. Mas o peso da interpretação normativa está com a posição de não capturar o foco para o elemento nativo, e adicionar uma captura remove ativamente uma capacidade que usuários de teclado têm por padrão.
A ressalva que completa a regra: focus traps manuais pertencem a modais customizados que não usam <dialog> — e se você construir um, você é responsável pelo inert no fundo, pois nada o gerencia por você. O conselho de “sempre capturar o foco” não está errado em geral; está errado quando aplicado ao elemento que já gerencia a contenção.
Uma implementação mínima e correta do <dialog> nativo
Aqui está a versão vanilla com os três aprimoramentos que valem a pena adicionar: fechamento ao clicar no backdrop, animação com respeito a prefers-reduced-motion, e nada mais. A restauração do foco já é gerenciada por close().
<button id="open">Abrir diálogo</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Confirmar alterações</h2>
<p>Suas edições serão aplicadas imediatamente.</p>
<form method="dialog">
<button value="cancel">Cancelar</button>
<button value="confirm">Confirmar</button>
</form>
</dialog>
const dlg = document.getElementById('dlg');
document.getElementById('open').addEventListener('click', () => dlg.showModal());
// Clique no backdrop fecha o diálogo (o comportamento nativo NÃO faz isso).
dlg.addEventListener('click', (e) => {
if (e.target === dlg) dlg.close();
});
aria-labelledby aponta para o cabeçalho visível, fornecendo ao diálogo seu nome acessível. <form method="dialog"> fecha o diálogo ao ser submetido e registra qual botão foi pressionado em dlg.returnValue — sem JavaScript adicional. A verificação do backdrop funciona porque clicar no ::backdrop registra como um clique no próprio elemento <dialog>, enquanto clicar no conteúdo registra em um elemento filho.
Anime-o sem conflitar com o top layer, e respeite as preferências de movimento:
dialog {
opacity: 0;
transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style {
dialog[open] { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
dialog { transition: none; }
}
@starting-style e transition-behavior: allow-discrete são o que fazem as transições de entrada funcionarem para um elemento que entra e sai do top layer. O bloco prefers-reduced-motion satisfaz o critério WCAG 2.3.3.
A versão React usa useRef para chamar os métodos imperativos e useId para uma associação de label segura para SSR:
import { useRef, useEffect, useId } from 'react';
function Dialog({ open, onClose, title, children }) {
const ref = useRef(null);
const titleId = useId();
useEffect(() => {
const el = ref.current;
if (open) el.showModal();
else el.close();
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby={titleId}
onClose={onClose}
onClick={(e) => { if (e.target === ref.current) ref.current.close(); }}
>
<h2 id={titleId}>{title}</h2>
{children}
</dialog>
);
}
O evento nativo close (disparado pelo ESC, pelo handler do backdrop ou pelo submit do formulário) aciona seu onClose, mantendo o estado do React sincronizado com o elemento. Use role="alertdialog" em vez do padrão para confirmações destrutivas, e não feche esses diálogos ao clicar no backdrop.
<dialog> nativo vs. biblioteca: a matriz de decisão para 2026
Use isso para decidir. Verifique novamente no npm no momento da integração, pois as versões de patch mudam.
| Opção | Quando vence | Desvantagem | Versão atual |
|---|---|---|---|
<dialog> nativo | A maioria dos modais; quando você quer zero dependências JS e a11y integrada | Menos controle de animação/empilhamento; você configura o fechamento pelo backdrop manualmente | Baseline desde março de 2022 |
| a11y-dialog (vanilla) | Projetos vanilla/não-React que precisam de um wrapper testado e eventos | Depende de aria-modal; limitações conhecidas em AT mobile | 8.1.5 |
| Radix / shadcn (React) | Equipes de design system; primitivos composáveis; fluxo de trabalho shadcn | Adiciona uma camada de primitivos e bundle | radix-ui unificado 1.6.0 |
| React Aria (React) | Aplicações críticas em a11y que precisam das garantias de comportamento da Adobe | API mais complexa; opinativa | @react-aria/dialog 3.5.34 |
| Headless UI (React) | Projetos Tailwind que querem primitivos sem estilo correspondentes | Apenas React; implementação customizada (não usa <dialog>) | 2.2.10 |
| Base UI (React) | Novos projetos que querem primitivos modernos da equipe MUI | Ecossistema mais jovem | stable v1.0 (dez. 2025) |
A versão mais recente do a11y-dialog é 8.1.5 e, conforme sua própria documentação de problemas conhecidos, ele depende de aria-modal (WAI-ARIA 1.1), cujo suporte não é ideal em certas tecnologias assistivas mobile — combiná-lo com o helper aria-hidden é a mitigação documentada.
O Headless UI para React está na versão 2.2.10, publicada em 7 de abril de 2026. Conforme a documentação do Headless UI, seu Dialog é um componente customizado baseado em div que move o foco para dentro e o captura — o que está correto, pois não é um <dialog> nativo, portanto deve capturar o foco. Conforme a documentação do React Aria, o foco é movido para dentro do diálogo na montagem, restaurado para o gatilho na desmontagem e contido enquanto aberto. Uma ressalva: a documentação do React Aria ainda descreve o elemento HTML <dialog> como não amplamente suportado — essa informação está desatualizada dado o status Baseline desde 2022, portanto não a herde.
O cenário de bibliotecas headless mudou genuinamente no final de 2025 e início de 2026. O Base UI, mantido pela equipe MUI e construído por ex-membros de Radix, Floating UI e Material UI, alcançou a versão estável v1.0 em dezembro de 2025, e o shadcn/ui consolidou seu uso do Radix no pacote unificado radix-ui em fevereiro de 2026. Um fato frequentemente confundido: a empresa-mãe do Radix, Modulz, foi adquirida pela WorkOS em 2022 — isso já tem quatro anos, não é novidade recente. A mudança realmente recente é a ascensão do Base UI como uma camada de primitivos alternativa. A maioria das equipes ainda consome Radix através do shadcn/ui.
Quando você não pode usar <dialog>: o checklist para modais customizados
Se você precisar construir um modal customizado — diálogos não-modais coexistentes, empilhamento exótico ou uma restrição de legado — você assume a responsabilidade por tudo que o elemento nativo gerenciava. No mínimo:
- mova o foco para dentro ao abrir e restaure-o ao fechar;
- aplique o atributo
inertao restante da aplicação (mais robusto quearia-hidden, poisinerttambém remove elementos da ordem de tabulação); - implemente um focus trap real que cicle Tab e Shift+Tab pelos filhos focalizáveis;
- adicione um handler de keydown para ESC;
- bloqueie a rolagem do fundo; e
- defina
role="dialog"/alertdialogcomaria-modal="true"e um nome acessível.
Este é o caso — e o único caso — em que “sempre capture o foco” é o conselho correto.
Bugs de acessibilidade em modais customizados como esses são exatamente o que o session replay em produção revela, pois ele reconstrói o rastro real de foco e teclado que um usuário experimentou. Você pode observar um usuário de teclado pressionando Tab para sair de um modal customizado e acessar o conteúdo do fundo — a assinatura inconfundível de um inert ausente ou de uma captura quebrada. Você vê o foco não ir a lugar nenhum após o fechamento de um modal, indicando que a referência ao elemento de abertura nunca foi armazenada. Você vê um modal que não pode ser fechado pelo teclado, porque uma implementação customizada foi entregue sem um handler de ESC. Esses são os modos de falha que auditorias aprovam e replays capturam.
Realidade dos leitores de tela: o ARIA correto não é suficiente
O ARIA correto é necessário, mas não suficiente. Em 2026, Safari com VoiceOver ainda pode deixar conteúdo estático e não focalizável dentro de um diálogo aria-modal inacessível ao cursor virtual — um problema conhecido do WebKit corroborado pelo artigo da Deque de dezembro de 2025 sobre diálogos modais ARIA. A implicação prática: um diálogo perfeitamente marcado ainda pode ocultar seu texto do corpo para um usuário do VoiceOver. Teste; não presuma.
Além disso, o NVDA pode não anunciar o papel de diálogo em todas as configurações, e o TalkBack pode perder conteúdo posicionado de forma absoluta. A conclusão é sobre processo, não marcação: teste em pelo menos duas combinações de leitor de tela/navegador — VoiceOver/Safari e NVDA/Firefox ou Chrome, no mínimo — porque uma auditoria automatizada confirma que os atributos estão presentes, não que uma tecnologia assistiva real alcança o conteúdo.
Conclusão
Recorra primeiro ao <dialog> nativo, escreva os quatro pequenos aprimoramentos (fechamento pelo backdrop, foco inicial deliberado, bloqueio de rolagem, animação com prefers-reduced-motion) e resista ao impulso de adicionar um focus trap que ele não precisa. Adote Radix, React Aria, Headless UI, a11y-dialog ou Base UI somente quando um requisito concreto — composabilidade, animação, empilhamento — superar um elemento sem dependências que já entrega a acessibilidade para você. Em seguida, abra um leitor de tela e confirme o que a auditoria não consegue: que um usuário real pode realmente alcançar cada palavra.
Perguntas Frequentes
Um elemento dialog nativo funciona sem JavaScript usando o atributo form method dialog?
Um diálogo pode abrir sem JavaScript apenas se você usar o atributo open, mas isso o renderiza como um diálogo não-modal sem top layer, sem fundo inert e sem contenção de foco. Os comportamentos modais exigem chamar showModal() em JavaScript, o que não pode ser acionado de forma declarativa. Você pode, no entanto, fechar um diálogo sem JavaScript adicional colocando um formulário com method='dialog' dentro dele; submeter esse formulário fecha o diálogo e registra o botão pressionado na propriedade returnValue.
Qual é a diferença entre role dialog e role alertdialog em um modal?
O role='dialog' padrão de um elemento dialog nativo é para modais gerais onde os usuários navegam ou inserem conteúdo, enquanto role='alertdialog' sinaliza uma mensagem urgente e interruptiva que requer uma resposta, como a confirmação de uma ação destrutiva. Leitores de tela anunciam o conteúdo de alertdialog de forma mais assertiva. Para modais alertdialog, não permita fechamento pelo backdrop ou descarte casual, pois o padrão pressupõe que o usuário deve fazer uma escolha deliberada em vez de fechar o diálogo acidentalmente.
Por que meu diálogo abre rolado até o final em vez do topo?
Isso acontece quando o primeiro elemento focalizável no DOM do diálogo está próximo ao final do conteúdo. Quando showModal() é executado, o navegador move o foco para esse primeiro controle focalizável e, se ele estiver no final, o diálogo abre rolado até ele. O W3C ARIA Authoring Practices Guide documenta esse mesmo comportamento. Corrija-o ordenando o código-fonte de modo que o cabeçalho venha antes do primeiro controle interativo — por exemplo, posicionando o botão de fechar após o título em vez de antes.
Posso estilizar um diálogo aberto com a pseudo-classe :open em vez do seletor de atributo open?
A pseudo-classe :open pode estilizar um diálogo aberto, mas se tornou Baseline recentemente disponível apenas em maio de 2026, quando o Safari 26.5 a implementou. Por ser recentemente disponível em vez de amplamente suportada em versões mais antigas de navegadores, o seletor entre plataformas mais seguro continua sendo o seletor de atributo dialog[open], que funciona desde que o elemento atingiu o status Baseline em março de 2022. Use o seletor de atributo como sua abordagem principal e trate :open como aprimoramento progressivo até que o suporte seja amplo.
O Headless UI usa o elemento dialog nativo e devo ainda capturar o foco com ele?
O componente Dialog do Headless UI é uma implementação customizada baseada em div, não o elemento HTML dialog nativo, portanto ele corretamente move o foco para dentro e o captura enquanto o usuário navega pelos elementos focalizáveis. Isso é consistente com a regra de que modais customizados que não usam dialog devem capturar o foco e gerenciar a inércia do fundo por conta própria. Você não deve remover seu focus trap. A orientação de não capturar o foco se aplica apenas ao elemento dialog nativo aberto com showModal(), onde o navegador já contém o foco na página.
Truly understand users experience
See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data.
Star on GitHub12k