B / Blog sobre React
Crear un modal accesible en React es más difícil de lo que parece
Un tutorial práctico sobre el foco, el teclado, el etiquetado, los portales, el scroll y los diálogos anidados que se esconden detrás de un modal en React.
- Accesibilidad en React
- Diálogos
- Gestión del foco
- Arquitectura UI
Un modal suele empezar como una pequeña tarea de interfaz: renderizar un panel sobre la página, oscurecer el fondo y añadir un botón de cierre. Puede parecer completo al usarlo con el ratón y seguir siendo un sistema de navegación roto para quien utiliza el teclado o un lector de pantalla.
Un modal accesible debe coordinar el foco, la entrada de teclado, la semántica del documento, las capas, el scroll y la limpieza. Cada aspecto es manejable por separado. La dificultad de ingeniería está en lograr que funcionen como un único sistema, incluso cuando cambia el contenido o se abre un segundo diálogo.
Este tutorial construye un modal educativo en React y analiza dónde se vuelve frágil una implementación aparentemente pequeña. El objetivo no es crear otra biblioteca de componentes, sino hacer visible el contrato de accesibilidad.
B-01
Empieza por el contrato de comportamiento
Antes de escribir JSX, describe qué debe ocurrir desde el punto de vista de la persona usuaria.
- Al activar el disparador se abre el diálogo y el foco se mueve a un lugar elegido dentro de él.
- Tab y Mayús+Tab permanecen dentro del diálogo activo mientras sea modal.
- Escape cierra solo el diálogo descartable que está en la parte superior.
- Al cerrar, el foco vuelve al elemento que abrió el diálogo o a una alternativa razonable si ese elemento ya no existe.
- El diálogo tiene un nombre y, opcionalmente, una descripción que las tecnologías de asistencia pueden anunciar.
- El contenido del fondo queda visualmente oculto y no admite interacción.
- La página detrás del diálogo no se desplaza, pero el contenido largo del propio diálogo sigue siendo utilizable.
- Un diálogo anidado toma el control temporalmente sin corromper el estado ni el historial de foco del diálogo padre.
Esa lista es el componente. La superposición, el panel redondeado y la animación son solo su presentación.
B-02
Dale al diálogo un nombre accesible real
ARIA no crea comportamiento, pero comunica el que el código implementa realmente. Un contenedor modal personalizado suele necesitar `role="dialog"` y `aria-modal="true"`. También necesita un nombre accesible.
El patrón más fiable consiste en renderizar un título visible y referenciarlo con `aria-labelledby`. Una frase breve de apoyo puede vincularse mediante `aria-describedby`.
const titleId = useId();
const descriptionId = useId();
<div
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
aria-modal="true"
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
{children}
<button type="button" onClick={onDismiss}>
Cerrar diálogo
</button>
</div>El botón de cierre necesita su propio nombre accesible y comprensible. Puede mostrar un icono, pero no debe anunciarse simplemente como «botón».
No incluyas automáticamente todo el cuerpo del diálogo en `aria-describedby`. Una frase corta funciona bien. En un documento largo, un formulario complejo o un conjunto estructurado de encabezados, suele ser más claro permitir que las personas que usan un lector de pantalla exploren el contenido con normalidad después de que el foco entre en el diálogo.
`aria-modal="true"` es una promesa, no una implementación. Indica a las tecnologías de asistencia que el resto de la página no está disponible. No contiene el foco, no bloquea el scroll, no vuelve inerte el fondo ni cierra el diálogo con Escape. El código debe hacer realidad esa promesa.
B-03
La colocación del foco es una decisión de producto
«Enfoca el primer elemento» suena razonable hasta que ese primer elemento es un botón destructivo o un campo que abre el teclado virtual en un teléfono.
Elige el foco inicial según el propósito y la forma del diálogo:
- En un formulario corto, enfoca el primer campo solo cuando escribir de inmediato sea la siguiente acción esperada.
- En una confirmación destructiva, enfoca la acción menos destructiva, normalmente Cancelar.
- En un diálogo largo o muy estructurado, enfoca un encabezado estático o un elemento introductorio con `tabIndex={-1}` para que la lectura empiece arriba sin saltarse contenido.
- En una elección sencilla, enfoca la acción segura más probable.
- Cuando una interacción táctil haya abierto el diálogo, valora si enfocar un campo ocultaría contexto importante con el teclado virtual.
Por tanto, la API del componente necesita algo más que un booleano `open`. Debe permitir que la funcionalidad que utiliza el diálogo designe el objetivo inicial correcto.
type ModalProps = {
initialFocusRef?: RefObject<HTMLElement | null>;
open: boolean;
onDismiss: () => void;
triggerRef?: RefObject<HTMLElement | null>;
};
useLayoutEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
const target = initialFocusRef?.current ?? dialogRef.current;
target?.focus();
});
return () => cancelAnimationFrame(frame);
}, [initialFocusRef, open]);Esta alternativa enfoca el contenedor del diálogo, que tiene `tabIndex={-1}`. Es más segura que dejar el foco detrás de la superposición, pero no sustituye la elección del objetivo adecuado para un flujo real.
B-04
Una trampa de foco es un límite vivo
Un diálogo modal contiene su secuencia de tabulación. Al pulsar Tab desde el último elemento tabulable, el foco vuelve al primero; con Mayús+Tab desde el primero, vuelve al último.
Una implementación simplificada puede consultar los elementos tabulables actuales cada vez que se pulsa Tab. Hacer la consulta al producirse el evento importa porque los errores de validación, los estados de carga, los campos condicionales y los botones deshabilitados pueden cambiar el conjunto después de abrir el diálogo.
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(",");
function getTabbableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(focusableSelector),
).filter(
(element) =>
!element.hidden &&
element.getAttribute("aria-hidden") !== "true" &&
!element.closest("[inert]"),
);
}
function keepTabInside(event: KeyboardEvent, dialog: HTMLElement) {
if (event.key !== "Tab") return;
const tabbable = getTabbableElements(dialog);
const first = tabbable[0];
const last = tabbable.at(-1);
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
dialog.focus();
return;
}
if (active === dialog) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}
if (event.shiftKey && (active === first || !dialog.contains(active))) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(active === last || !dialog.contains(active))
) {
event.preventDefault();
first.focus();
}
}Este ejemplo sirve para aprender, pero no es un motor completo de tabulación. Las aplicaciones reales deben contemplar la visibilidad, los grupos de botones de opción, los elementos details, los shadow roots, los iframes, las regiones `contenteditable`, los elementos desplazados durante una animación y el comportamiento del foco específico de cada navegador. Un selector que encuentra nodos que parecen enfocables no representa necesariamente el orden de tabulación real del navegador.
La trampa también debe proteger su límite cuando el foco se mueve mediante scripts o interacciones con el puntero, no solo con Tab. El contenido del fondo debe ser inerte para que no pueda recibir el foco ni clics.
B-05
Escape debe pertenecer a la capa activa
Admitir Escape es sencillo con un solo modal y fácil de romper con dos. Un listener a nivel de documento instalado por cada diálogo abierto puede cerrar toda la pila con una única pulsación.
Cada diálogo necesita una identidad, y un gestor de capas compartido debe saber qué identidad está actualmente en la parte superior.
const modalStack: symbol[] = [];
export function pushModal(id: symbol) {
modalStack.push(id);
}
export function removeModal(id: symbol) {
const index = modalStack.lastIndexOf(id);
if (index >= 0) modalStack.splice(index, 1);
}
export function isTopModal(id: symbol) {
return modalStack.at(-1) === id;
}El manejador del teclado puede ignorar entonces los eventos que pertenecen a una capa superior.
useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
const dialog = dialogRef.current;
if (!dialog || !isTopModal(layerId.current)) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onDismiss();
return;
}
keepTabInside(event, dialog);
}
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [onDismiss, open]);Escape es solo una salida. Todo modal debe incluir también un control visible para cerrar, cancelar o completar. Una persona que usa un lector de pantalla táctil no puede depender de una tecla Escape física.
Algunos flujos deben proteger trabajo no guardado. No deshabilites Escape en silencio dejando a la persona atrapada. Permite que Escape solicite el cierre y presenta después una confirmación comprensible con una forma explícita de quedarse o salir.
B-06
Restaura el foco a un lugar significativo
Cuando un diálogo se cierra, una persona que usa el teclado necesita saber dónde se encuentra. En el caso habitual, el foco vuelve al disparador.
Captura el elemento activo antes de que el foco entre en el modal. Durante la limpieza, da prioridad a una referencia explícita al disparador y después al elemento capturado. Comprueba que el objetivo siga conectado, porque la acción realizada en el diálogo puede haberlo eliminado.
const previousFocusRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (!open) return;
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
return () => {
const target = triggerRef?.current ?? previousFocusRef.current;
if (target?.isConnected) {
target.focus();
}
};
}, [open, triggerRef]);Restaurar el nodo anterior no siempre es correcto. Si un diálogo de confirmación de borrado elimina la fila que contenía su disparador, el foco podría pasar a la fila siguiente, a la anterior o al encabezado de la colección. La funcionalidad es dueña de esa decisión porque solo ella entiende qué sigue teniendo sentido después de la acción.
Con diálogos anidados, la restauración se convierte en una pila. Al cerrar el hijo, el foco vuelve al control del padre que lo abrió. Al cerrar después el padre, el foco vuelve al disparador de la página. Una única variable global `previousFocus` no puede representar ambas transiciones.
B-07
Los portales resuelven problemas de layout, no el comportamiento modal
Los portales de React permiten que un diálogo escape de ancestros con `overflow: hidden`, transformaciones y contextos de apilamiento locales. El diálogo puede renderizarse bajo un contenedor estable cercano al body del documento y seguir siendo hijo del mismo árbol de React.
if (!open) return null;
return createPortal(
<div className="modal-layer" role="presentation">
<div className="modal-backdrop" />
<div
aria-labelledby={titleId}
aria-modal="true"
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
{children}
</div>
</div>,
document.body,
);Cambia la posición física en el DOM, pero no la relación en React. El contexto sigue funcionando y los eventos del portal continúan propagándose por el árbol de React. Por tanto, un clic dentro del modal puede alcanzar un manejador React ancestro aunque los nodos DOM estén muy separados. El cierre al pulsar el fondo y los manejadores de clic del padre necesitan límites de evento deliberados.
Un portal tampoco vuelve inerte el fondo. En un diálogo ARIA personalizado, el gestor modal debe deshabilitar todo el contenido fuera de la capa activa y conservar cualquier estado `inert` o `aria-hidden` previo. Esa contabilidad se complica cuando varias raíces de React, overlays de terceros o diálogos anidados comparten la página.
B-08
El bloqueo del scroll es estado compartido
Definir `document.body.style.overflow = "hidden"` funciona en una demostración rápida de escritorio. También puede desplazar el layout cuando desaparece la barra de scroll, perder la posición de la página en navegadores móviles y desbloquear el documento demasiado pronto cuando se cierra un diálogo anidado.
Un gestor mínimo del scroll necesita conteo de referencias y restaurar exactamente los estilos que modifica.
let scrollLocks = 0;
let previousOverflow = "";
let previousPaddingInlineEnd = "";
export function lockPageScroll() {
scrollLocks += 1;
if (scrollLocks !== 1) return;
const body = document.body;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
previousOverflow = body.style.overflow;
previousPaddingInlineEnd = body.style.paddingInlineEnd;
body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
body.style.paddingInlineEnd = `${scrollbarWidth}px`;
}
}
export function unlockPageScroll() {
scrollLocks = Math.max(0, scrollLocks - 1);
if (scrollLocks !== 0) return;
document.body.style.overflow = previousOverflow;
document.body.style.paddingInlineEnd = previousPaddingInlineEnd;
}Incluso esto es solo un punto de partida. El código de producción debe gestionar el viewport móvil, el overscroll, las zonas seguras, los layouts de derecha a izquierda, los encabezados fijos, los gutters de las barras de scroll y el caso en que otra parte de la aplicación también posea un bloqueo. El panel del diálogo debe admitir por separado contenido largo sin ocultar el título ni el control de cierre.
B-09
Los diálogos anidados revelan todas las suposiciones débiles
Imagina un modal para editar un perfil que abre un segundo diálogo para confirmar que se descartan los cambios. Mientras la confirmación está abierta:
- La confirmación es el único diálogo activo.
- El foco queda contenido dentro de la confirmación, no en el diálogo padre.
- Escape cierra solo la confirmación.
- El padre permanece montado, pero no admite interacción.
- El scroll de la página continúa bloqueado cuando se cierra la confirmación.
- El foco vuelve al control del padre que abrió la confirmación.
- Al cerrar posteriormente el padre, el foco se restaura al disparador original de la página.
Ese comportamiento exige una pila de ámbitos de foco, identidades de capa, regiones inertes y bloqueos del scroll. El z-index solo establece el orden de pintado. Dos componentes modales independientes pueden parecer bien apilados mientras ambos escuchan Escape, compiten por restaurar el foco y creen ser dueños de los estilos del body.
Evita diálogos anidados cuando la interacción pueda convertirse en un único flujo más claro. A veces una confirmación puede sustituir el contenido del diálogo padre, o un aviso en línea puede mantener la decisión en su contexto. Cuando el anidamiento representa una jerarquía real, utiliza un único sistema de overlays que coordine todas las capas.
B-10
El elemento dialog nativo reduce la superficie a gestionar
El elemento HTML `dialog`, abierto con `showModal()`, entra en la capa superior del navegador y vuelve inerte el resto del documento que lo contiene. Los navegadores también ofrecen el comportamiento modal de Escape y un fondo. Es una mejora significativa frente a recrear la plataforma con un `div` genérico.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog aria-labelledby={titleId} ref={dialogRef}>
<h2 id={titleId}>{title}</h2>
{children}
<button type="button" onClick={onDismiss}>
Cerrar diálogo
</button>
</dialog>
);El comportamiento nativo no elimina las decisiones de producto. La aplicación sigue siendo responsable del nombre accesible, del foco inicial apropiado, de un cierre visible, de sincronizar el estado de React, del destino del foco después de la acción, del layout para contenido largo, de la animación y de verificar las combinaciones de navegador y tecnología de asistencia que admite.
Trata `dialog` como una primitiva de plataforma más sólida, no como permiso para dejar de comprobar el resultado.
B-11
Cuándo es más segura una biblioteca de componentes consolidada
Construir la versión educativa es útil porque revela el contrato. Entregar esa misma versión como infraestructura compartida es una decisión distinta.
Una biblioteca consolidada de componentes accesibles suele ser más segura cuando la aplicación presenta cualquiera de estas condiciones:
- Más de un diálogo o más de un equipo consume la primitiva.
- Existen diálogos anidados, menús que abren diálogos, popovers dentro de diálogos u otras combinaciones de overlays.
- El sistema incluye renderizado en servidor, hidratación, animaciones de salida o portales hacia contenedores personalizados.
- Hay formularios complejos con campos dinámicos, validación, estados asíncronos o acciones destructivas.
- Safari móvil, los lectores de pantalla táctiles, los layouts con zoom o los teclados virtuales forman parte de la matriz de soporte.
- Un sistema de diseño necesita un comportamiento modal coherente en varios productos.
- No hay capacidad dedicada para mantener a lo largo del tiempo el foco, la inercia, el scroll y los casos límite de los navegadores.
Bibliotecas como React Aria Components, Radix Primitives, Base UI y Ariakit ya han invertido en ámbitos de foco, cierre, portales y coordinación de overlays. Los criterios importantes no son las capturas de pantalla ni los estilos predeterminados. Revisa el contrato de teclado, la API de nombres accesibles, el comportamiento de los overlays anidados, la estrategia de scroll, el soporte para renderizado en servidor, el historial de mantenimiento, el coste en el bundle y la facilidad para aplicar tus propios tokens semánticos de diseño.
Una biblioteca no vuelve accesible por sí sola la funcionalidad terminada. El equipo debe proporcionar un título útil, elegir el foco inicial y restaurado apropiados, mantener controles de cierre visibles, escribir mensajes de validación comprensibles y comprobar el contenido real. La biblioteca se hace cargo de la infraestructura difícil; el equipo de producto conserva la responsabilidad sobre el significado.
B-12
Verifica la interacción, no el marcado
Las comprobaciones automatizadas pueden detectar un nombre ausente o un atributo ARIA inválido. No pueden decidir si el foco llegó a la acción más segura o si volver a un disparador eliminado tiene sentido.
Comprueba manualmente la secuencia completa:
- Abre el diálogo usando solo el teclado y confirma que el foco entra una única vez.
- Recorre hacia delante y hacia atrás todos los elementos interactivos, incluidos los controles que aparecen dinámicamente.
- Pulsa Escape y confirma que solo se cierra el diálogo activo.
- Cierra con cada acción visible y verifica que el foco se restaura al objetivo correcto.
- Intenta enfocar, pulsar y desplazar el fondo mientras el diálogo está abierto.
- Abre y cierra un diálogo anidado en ambos órdenes y comprueba el historial de foco y el bloqueo del scroll.
- Prueba contenido corto, contenido largo, errores de validación, estados de carga y la ausencia de hijos tabulables.
- Revisa layouts estrechos equivalentes al 320 %, con zoom del navegador y reducción de movimiento.
- Utiliza al menos las combinaciones de lector de pantalla y navegador definidas en la política de soporte del producto.
- Confirma que un error, un disparador eliminado, un cambio de ruta o una animación interrumpida siguen limpiando el estado global.
Los fallos de accesibilidad son aquí fallos de gestión de estado, de ciclo de vida y de arquitectura. Merecen la misma atención de diseño que la propiedad de los datos o la recuperación ante errores, porque determinan si una persona puede completar el flujo.