Construcción de Modales Accesibles: `<dialog>` Nativo o una Librería
<dialog> nativo o biblioteca para modales accesibles: foco, fondo inert, tecla ESC, bloqueo del scroll y cuándo siguen importando los focus traps.
En 2026, opta por defecto al elemento nativo <dialog> con showModal(): te proporciona contención de foco, cierre con ESC, renderizado en la capa superior, prevención del desplazamiento del fondo y un aria-modal implícito sin ningún JavaScript — recurre a una librería como Radix, React Aria, Headless UI o a11y-dialog solo cuando te enfrentes a apilamiento complejo, requisitos de animación avanzados, composabilidad en un sistema de diseño, o una limitación nativa concreta que hayas medido.
Este veredicto revierte años de sabiduría convencional. El consejo estándar — usar portal para el modal, atrapar el foco manualmente, ocultar el fondo con aria-hidden, gestionar ESC a mano — describía lo que había que hacer antes de que <dialog> e inert estuvieran disponibles. El elemento <dialog> es Baseline en todos los navegadores principales desde marzo de 2022, por lo que la mayor parte de ese andamiaje ya es un comportamiento predeterminado del navegador. Este artículo resuelve la elección entre nativo y librería con criterios concretos, aclara el matiz de la trampa de foco donde la mayoría de las guías repiten consejos obsoletos, ofrece una implementación mínima verificada en vanilla JS y React, y concluye con la realidad de los lectores de pantalla que el ARIA correcto por sí solo no puede solucionar.
Puntos Clave
- El
<dialog>nativo abierto conshowModal()coloca el diálogo en la capa superior del navegador y hace que el resto de la página seainert, por lo que el foco queda contenido en el modal a nivel de página sin necesidad de ninguna trampa de foco en JavaScript. - No añadas una trampa de foco en JavaScript a un
<dialog>nativo: lo único que Tab puede seguir alcanzando es el propio chrome del navegador, al que los usuarios de teclado deben poder acceder exactamente igual que los usuarios de ratón. - Las trampas de foco manuales corresponden a modales personalizados que no usan
<dialog>— y si construyes uno, eres responsable del atributoinerten el fondo, porque nada lo gestiona por ti. - El problema nativo más común es silencioso: si el primer control enfocable está al final del DOM del diálogo,
showModal()lo enfoca y el modal se abre desplazado hasta el final — corrígelo colocando el botón de cierre después del encabezado en el orden del código fuente. - El ARIA correcto es necesario pero no suficiente: a partir de 2026, Safari junto con VoiceOver aún puede dejar el contenido estático dentro de un diálogo
aria-modalinaccesible, así que prueba con lectores de pantalla reales.
Lo que un modal accesible debe hacer realmente
Un modal accesible tiene cinco comportamientos innegociables: mueve el foco hacia sí mismo al abrirse, hace que el fondo sea inaccesible y no interactivo, puede cerrarse mediante teclado (ESC) y un control visible, restaura el foco al elemento que lo activó al cerrarse, y expone un rol de diálogo con un nombre accesible para que los lectores de pantalla lo anuncien. Estos se corresponden directamente con los criterios de éxito de WCAG — orden del foco (2.4.3), operabilidad por teclado (2.1.1, 2.1.2) y nombre/rol/valor (4.1.2). La referencia rápida completa de WCAG 2.2 lista el resto; este conjunto reducido es de lo que depende la viabilidad de un modal.
La razón por la que estos aspectos fallan es que cada uno es una pieza de maquinaria independiente en un modal position: fixed construido a mano. Hay que programar el movimiento del foco, su restauración, el listener de ESC, el ocultamiento del fondo y el cableado del rol de forma independiente — y cualquiera de ellos que regrese silenciosamente produce un modal que parece correcto para un usuario de ratón y está roto para todos los demás. El argumento a favor del elemento nativo es que colapsa la mayor parte de esa maquinaria en una sola llamada al DOM.
Lo que <dialog> y showModal() te dan gratis — y lo que no
Discover how at OpenReplay.com.
Llamar a showModal() en un <dialog> te proporciona, sin más JavaScript que la propia llamada: el foco inicial movido al diálogo, el cierre mediante la tecla Esc gestionado por el navegador, renderizado en la capa superior que evita los problemas con z-index y los portales, un fondo que puedes estilizar con ::backdrop, un role="dialog" implícito con aria-modal="true", y un fondo inert para que Tab no pueda alcanzar el contenido de la página que hay detrás. close() restaura entonces el foco al elemento que abrió el diálogo. Jared Cunha, quien reconstruyó el modal del U.S. Web Design System sobre <dialog>, reporta que la reescritura pasó de aproximadamente 400 líneas de JavaScript más cuatro utilidades a unas 38 líneas — los comportamientos de accesibilidad que antes había que programar son ahora comportamientos predeterminados del navegador.
Lo que no te proporciona, y las soluciones:
| Problema | Qué ocurre | Solución |
|---|---|---|
| Primer elemento enfocable al final del DOM | showModal() lo enfoca y el diálogo se abre desplazado hasta el final | Coloca el botón de cierre después del encabezado en el orden del código fuente; o establece el foco inicial de forma deliberada |
Atributo autofocus | No es fiable para mover el foco inicial en algunos navegadores de escritorio | Establece el foco en JS después de showModal(), apuntando al elemento correcto |
| Clic en el fondo | El diálogo nativo no se cierra al hacer clic fuera por defecto | Escucha los clics donde event.target === dialogEl |
| Desplazamiento del fondo | La página detrás del diálogo puede seguir desplazándose | Bloquea el desplazamiento explícitamente (ver más abajo) |
El comportamiento de apertura desplazada al final no es una peculiaridad de una librería concreta — la guía de prácticas de autoría ARIA del W3C documenta que colocar el foco inicial en un elemento cerca del final del contenido del diálogo puede hacer que el diálogo se abra fuera de la vista. La solución más limpia es el orden del código fuente: el encabezado primero, el botón de cierre después, para que el lector de pantalla anuncie el título antes que el control y el diálogo se abra en la parte superior.
Para bloquear el desplazamiento, captura la posición de desplazamiento, bloquea el body y restáuralo al cerrar:
:root:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable; /* evita el salto de maquetación cuando desaparece la barra de desplazamiento */
}
scrollbar-gutter: stable reserva el ancho de la barra de desplazamiento para que la página no se desplace lateralmente cuando se bloquea el scroll. En iOS Safari, overflow: hidden en el body es notoriamente ineficaz; capturar window.scrollY y aplicar position: fixed con un top negativo es el enfoque multiplataforma más fiable cuando se necesita.
La cuestión de la trampa de foco, resuelta: no atrapes un diálogo nativo
No añadas una trampa de foco en JavaScript a un <dialog> nativo abierto con showModal(). El navegador ya lo coloca en la capa superior y hace que el resto de la página sea inert, por lo que el foco está contenido — lo único que Tab puede seguir alcanzando es el propio chrome del navegador (barra de direcciones, pestañas, extensiones), al que los usuarios de teclado deben poder acceder, exactamente igual que los usuarios de ratón. Este es el consejo desactualizado que más se repite en los tutoriales sobre modales, y es incorrecto para el elemento nativo.
El razonamiento proviene de reconocidas autoridades en accesibilidad, sintetizado por Zell Liew en el artículo de CSS-Tricks “There is No Need to Trap Focus on a Dialog Element”. La posición de Scott O’Hara es que WCAG no requiere normativamente la trampa de foco; la recomendación de trampa provino de una guía informativa escrita antes de que inert y <dialog> tuvieran amplio soporte, cuando los diálogos programados hacían que atrapar el foco fuera más fácil que hacer que los elementos externos no fueran accesibles por teclado. El planteamiento de Léonie Watson es que los usuarios deben mantener las mismas opciones de control dentro de un diálogo que en una página normal — deben poder seguir accediendo a los controles del navegador. La posición del Grupo de Trabajo APA del W3C es que el comportamiento del diálogo nativo debe mantenerse tal como está, en parte porque acceder al chrome del navegador mediante Tab es un mecanismo de escape en contextos de quiosco y entornos restringidos.
Este es un debate genuino y actual, no una ley establecida. Jared Cunha, en el trabajo de USWDS citado anteriormente, sigue añadiendo una función de trampa de foco en el elemento nativo. Su razonamiento es la cautela del profesional. Pero el peso de la interpretación normativa se sitúa en la posición de no atrapar para el elemento nativo, y añadir una trampa elimina activamente una capacidad que los usuarios de teclado tienen por defecto.
La salvedad que completa la regla: las trampas de foco manuales corresponden a modales personalizados que no usan <dialog> — y si construyes uno, eres responsable del atributo inert en el fondo, porque nada lo gestiona por ti. El consejo de “siempre atrapar el foco” no está equivocado en general; está equivocado cuando se aplica al elemento que ya gestiona la contención.
Una implementación mínima y correcta del <dialog> nativo
Esta es la versión vanilla con las tres mejoras que vale la pena añadir: cierre al hacer clic en el fondo, animación que respeta prefers-reduced-motion, y nada más. La restauración del foco ya la gestiona close().
<button id="open">Open dialog</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Confirm changes</h2>
<p>Your edits will be applied immediately.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Confirm</button>
</form>
</dialog>
const dlg = document.getElementById('dlg');
document.getElementById('open').addEventListener('click', () => dlg.showModal());
// El clic en el fondo cierra el diálogo (el comportamiento nativo NO hace esto).
dlg.addEventListener('click', (e) => {
if (e.target === dlg) dlg.close();
});
aria-labelledby apunta al encabezado visible, proporcionando al diálogo su nombre accesible. <form method="dialog"> cierra el diálogo al enviarlo y registra qué botón se pulsó en dlg.returnValue — sin JS adicional. La comprobación del fondo funciona porque hacer clic en ::backdrop se registra como un clic en el propio elemento <dialog>, mientras que hacer clic en el contenido se registra en un elemento hijo.
Anímalo sin conflictos con la capa superior y respetando las preferencias de movimiento:
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 y transition-behavior: allow-discrete son lo que hace que las transiciones de entrada funcionen para un elemento que entra y sale de la capa superior. El bloque prefers-reduced-motion satisface el criterio WCAG 2.3.3.
La versión en React usa useRef para llamar a los métodos imperativos y useId para una asociación de etiquetas segura en 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>
);
}
El evento nativo close (disparado por ESC, el manejador del fondo o el envío del formulario) gestiona tu onClose, manteniendo el estado de React sincronizado con el elemento. Usa role="alertdialog" en lugar del predeterminado para confirmaciones destructivas, y no cierres esos diálogos al hacer clic en el fondo.
<dialog> nativo vs. una librería: la matriz de decisión para 2026
Usa esto para decidir. Verifica las versiones en npm en el momento de la integración, ya que las versiones de parche cambian.
| Opción | Cuándo gana | Contrapartida | Versión actual |
|---|---|---|---|
<dialog> nativo | La mayoría de los modales; cuando quieres cero dependencias JS y accesibilidad integrada | Menos control sobre animación/apilamiento; debes gestionar el cierre por clic en el fondo | Baseline desde marzo de 2022 |
| a11y-dialog (vanilla) | Proyectos vanilla/no-React que necesitan un wrapper probado y eventos | Depende de aria-modal; limitaciones conocidas en AT móvil | 8.1.5 |
| Radix / shadcn (React) | Equipos con sistema de diseño; primitivas componibles; flujo de trabajo con shadcn | Añade una capa de primitivas y peso al bundle | radix-ui unificado 1.6.0 |
| React Aria (React) | Aplicaciones críticas en accesibilidad que requieren las garantías de comportamiento de Adobe | API más pronunciada; opinionado | @react-aria/dialog 3.5.34 |
| Headless UI (React) | Proyectos con Tailwind que buscan primitivas sin estilos integradas | Solo para React; implementación personalizada (no usa <dialog>) | 2.2.10 |
| Base UI (React) | Proyectos nuevos que buscan primitivas modernas del equipo de MUI | Ecosistema más joven | v1.0 estable (dic. 2025) |
La última versión de a11y-dialog es la 8.1.5, y según su propia documentación de problemas conocidos, depende de aria-modal (WAI-ARIA 1.1), con un soporte que no es óptimo en ciertas tecnologías de asistencia móvil — combinarlo con el helper aria-hidden es la mitigación documentada.
Headless UI para React está en la versión 2.2.10, publicada el 7 de abril de 2026. Según la documentación de Headless UI, su Dialog es un componente personalizado basado en div que mueve el foco hacia adentro y lo atrapa — lo cual es correcto, porque no es un <dialog> nativo, por lo que debe atraparlo. Según la documentación de React Aria, el foco se mueve al diálogo al montarse y se restaura al elemento activador al desmontarse, y queda contenido mientras está abierto. Una advertencia: la documentación de React Aria aún describe el elemento HTML <dialog> como no ampliamente soportado — esa afirmación está desactualizada dado su estado Baseline desde 2022, así que no la heredes.
El panorama de las librerías headless cambió genuinamente a finales de 2025 y principios de 2026. Base UI, mantenido por el equipo de MUI y construido por ex miembros de Radix, Floating UI y Material UI, alcanzó una v1.0 estable en diciembre de 2025, y shadcn/ui consolidó su uso de Radix en el paquete unificado radix-ui en febrero de 2026. Un hecho frecuentemente confundido: la empresa matriz de Radix, Modulz, fue adquirida por WorkOS en 2022 — eso tiene cuatro años, no es una novedad reciente. El cambio realmente reciente es el surgimiento de Base UI como capa de primitivas alternativa. La mayoría de los equipos siguen consumiendo Radix a través de shadcn/ui.
Cuando no puedes usar <dialog>: la lista de verificación para modales personalizados
Si debes construir un modal personalizado — diálogos no modales que coexisten, apilamiento exótico o una restricción heredada — recuperas la responsabilidad de todo lo que el elemento nativo gestionaba. Como mínimo:
- mueve el foco hacia adentro al abrir y restáuralo al cerrar;
- aplica el atributo
inertal resto de la aplicación (más robusto quearia-hidden, ya queinerttambién elimina los elementos del orden de tabulación); - implementa una trampa de foco real que cicle Tab y Shift+Tab sobre los elementos enfocables hijos;
- añade un manejador de keydown para ESC;
- bloquea el desplazamiento del fondo; y
- establece
role="dialog"/alertdialogconaria-modal="true"y un nombre accesible.
Este es el caso — y el único caso — en que el consejo de “siempre atrapar el foco” es correcto.
Los errores de accesibilidad en modales personalizados como estos son exactamente lo que la reproducción de sesiones en producción pone de manifiesto, porque reconstruye el rastro real de foco y teclado que experimentó un usuario. Puedes ver a un usuario de teclado salir con Tab de un modal personalizado hacia el contenido del fondo — la firma inconfundible de un inert ausente o una trampa rota. Ves el foco aterrizar en ningún lugar después de que se cierra un modal, la señal de que la referencia al elemento activador nunca se almacenó. Ves un modal que no puede cerrarse con el teclado en absoluto, porque una implementación personalizada se publicó sin un manejador de ESC. Estos son los modos de fallo que las auditorías aprueban y las reproducciones detectan.
La realidad de los lectores de pantalla: el ARIA correcto no es suficiente
El ARIA correcto es necesario pero no suficiente. A partir de 2026, Safari junto con VoiceOver aún puede dejar el contenido estático y no enfocable dentro de un diálogo aria-modal inaccesible al cursor virtual — un problema conocido de WebKit corroborado por el artículo de Deque de diciembre de 2025 sobre diálogos modales ARIA. La implicación práctica: un diálogo perfectamente marcado puede seguir ocultando su texto principal a un usuario de VoiceOver. Pruébalo; no lo des por supuesto.
Más allá de eso, NVDA puede no anunciar el rol de diálogo en todas las configuraciones, y TalkBack puede pasar por alto el contenido con posicionamiento absoluto. La conclusión es un proceso, no un marcado: prueba en al menos dos combinaciones de lector de pantalla/navegador — VoiceOver/Safari y NVDA/Firefox o Chrome como mínimo — porque una auditoría automatizada confirma que los atributos están presentes, no que una tecnología de asistencia real llegue al contenido.
Conclusión
Recurre primero al <dialog> nativo, escribe las cuatro pequeñas mejoras (cierre por clic en el fondo, foco inicial deliberado, bloqueo de desplazamiento, animación con reducción de movimiento) y resiste la tentación de añadir una trampa de foco que no necesita. Incorpora Radix, React Aria, Headless UI, a11y-dialog o Base UI solo cuando un requisito concreto — composabilidad, animación, apilamiento — supere las ventajas de un elemento sin dependencias que gestiona la accesibilidad por ti. Luego abre un lector de pantalla y confirma lo que la auditoría no puede: que un usuario real puede acceder a cada palabra.
Preguntas Frecuentes
¿Funciona un elemento dialog nativo sin JavaScript usando el atributo form method dialog?
Un diálogo puede abrirse sin JavaScript solo si usas el atributo open, pero eso lo renderiza como un diálogo no modal sin capa superior, sin fondo inert y sin contención de foco. Los comportamientos modales requieren llamar a showModal() en JavaScript, lo cual no puede activarse de forma declarativa. Sin embargo, puedes cerrar un diálogo sin JavaScript adicional colocando un formulario con method='dialog' dentro de él; al enviar ese formulario se cierra el diálogo y registra el botón pulsado en la propiedad returnValue.
¿Cuál es la diferencia entre role dialog y role alertdialog en un modal?
El role='dialog' predeterminado de un elemento dialog nativo es para modales generales donde los usuarios navegan o introducen contenido, mientras que role='alertdialog' señala un mensaje urgente e interruptivo que requiere una respuesta, como la confirmación de una acción destructiva. Los lectores de pantalla anuncian el contenido de alertdialog de forma más asertiva. Para los modales con alertdialog, no permitas el cierre por clic en el fondo ni el cierre casual, porque el patrón asume que el usuario debe hacer una elección deliberada en lugar de cerrar el diálogo accidentalmente.
¿Por qué mi diálogo se abre desplazado hasta el final en lugar de la parte superior?
Esto ocurre cuando el primer elemento enfocable en el DOM del diálogo se encuentra cerca del final del contenido. Cuando se ejecuta showModal(), el navegador mueve el foco a ese primer control enfocable, y si está al final, el diálogo se abre desplazado hasta él. La Guía de Prácticas de Autoría ARIA del W3C documenta este mismo comportamiento. Corrígelo ordenando el código fuente de modo que el encabezado aparezca antes del primer control interactivo, por ejemplo colocando el botón de cierre después del título en lugar de antes.
¿Puedo estilizar un diálogo abierto con la pseudo-clase :open en lugar del selector de atributo open?
La pseudo-clase :open puede estilizar un diálogo abierto, pero se convirtió en Baseline de nueva disponibilidad solo a partir de mayo de 2026, cuando Safari 26.5 la incorporó. Dado que es de nueva disponibilidad en lugar de estar ampliamente soportada en versiones anteriores de navegadores, el selector multiplataforma más seguro sigue siendo el selector de atributo dialog[open], que funciona desde que el elemento alcanzó el estado Baseline en marzo de 2022. Usa el selector de atributo como enfoque principal y trata :open como mejora progresiva hasta que el soporte sea amplio.
¿Headless UI usa el elemento dialog nativo y debo seguir atrapando el foco con él?
El componente Dialog de Headless UI es una implementación personalizada basada en div, no el elemento HTML dialog nativo, por lo que correctamente mueve el foco hacia adentro y lo atrapa mientras el usuario navega por los elementos enfocables. Esto es coherente con la regla de que los modales personalizados que no usan dialog deben atrapar el foco y gestionar la inercia del fondo por sí mismos. No debes eliminar su trampa de foco. La guía de no atrapar el foco aplica únicamente al elemento dialog nativo abierto con showModal(), donde el navegador ya contiene el foco en la 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