Crear modales accesibles con Claude Code: React, dialog y foco
Guía práctica para crear modales con Claude Code: dialog, React, foco, errores comunes, pruebas y accesibilidad.
Un diálogo modal es una capa temporal que pide al usuario una decisión o una entrada breve antes de volver a la página. No basta con dibujar una caja centrada. Un modal correcto bloquea el fondo, mueve el foco dentro del diálogo, permite cerrar de forma predecible y devuelve el foco al botón que lo abrió.
Si pides a Claude Code “haz un modal bonito”, puede generar algo visualmente aceptable pero frágil: Escape no cierra, Tab se escapa al fondo, el lector de pantalla no anuncia el título o los botones se pierden en móvil. Esta guía convierte esa petición vaga en un brief práctico, ejemplos copiables, casos de uso, errores frecuentes y pruebas.
Para revisar con criterio, usa las fuentes oficiales: MDN sobre el elemento <dialog>, WAI-ARIA APG Modal Dialog Pattern, WCAG Focus Order y Focus Visible. En ClaudeCodeLab también conviene leer accesibilidad con Claude Code, Radix UI, command palette y toast notifications.
Antes de construir
Un modal encaja cuando la tarea es corta y debe pausar el flujo actual: confirmar una eliminación, cancelar un plan, cambiar permisos, invitar a un miembro, iniciar sesión antes de pagar o abrir una paleta de comandos.
No encaja bien con formularios largos, textos legales completos, flujos de varias páginas, anuncios agresivos ni avisos que el usuario puede leer después. Antes de pedir código, decide si la acción merece bloquear la página, dónde debe ir el primer foco, qué acciones cierran el diálogo y si todo funciona a 320px de ancho.
Algunas definiciones sencillas ayudan a Claude Code. El foco es “la posición actual del teclado”. Un focus trap mantiene Tab dentro del diálogo. inert significa que el fondo deja de ser interactivo. ARIA son atributos que explican el significado de la UI a tecnologías de asistencia.
flowchart TD
A["El usuario pulsa el disparador"] --> B["Abrir con dialog.showModal()"]
B --> C["Mover foco al título o primera acción"]
C --> D["Comprobar Tab, Shift+Tab y Escape"]
D --> E["Separar confirmar, cancelar y clic exterior"]
E --> F["Devolver foco al disparador"]
Brief para Claude Code
Empieza por el comportamiento, no por los colores. Puedes pegar este brief y cambiar solo las rutas.
Añade un diálogo modal a la pantalla React + TypeScript existente.
Requisitos:
- Lee los botones, formularios, CSS y tests existentes antes de editar.
- Prioriza el elemento HTML dialog. Si no sirve, explica por qué.
- Al abrir, mueve el foco al título o a la primera acción útil.
- Trata Escape, cancelar, confirmar y clic exterior por separado.
- Al cerrar, devuelve el foco al botón que abrió el diálogo.
- Usa aria-labelledby y aria-describedby si hay descripción breve.
- No elimines outline. Usa :focus-visible para mostrar el foco.
- A 320px de ancho, el contenido y los botones deben seguir usándose.
- Deja casos de fallo y pasos de verificación manual en el handoff.
Archivos permitidos:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts
HTML ejecutable
Guarda esto como modal-demo.html y ábrelo en el navegador. Demuestra showModal(), close(), clic exterior y retorno de foco sin framework.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dialog demo</title>
<style>
body {
font-family: system-ui, sans-serif;
line-height: 1.7;
padding: 2rem;
}
button {
font: inherit;
border: 0;
border-radius: 6px;
padding: 0.7rem 1rem;
cursor: pointer;
}
.danger {
background: #dc2626;
color: white;
}
dialog {
width: min(calc(100vw - 2rem), 28rem);
border: 0;
border-radius: 8px;
padding: 0;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
dialog::backdrop {
background: rgb(15 23 42 / 0.58);
}
.modal-body {
padding: 1.25rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
</style>
</head>
<body>
<main>
<h1>Ajustes del proyecto</h1>
<p>Usa un modal solo para acciones que deben pausar la página.</p>
<button id="open-dialog" class="danger" type="button">
Eliminar proyecto
</button>
</main>
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<div class="modal-body">
<h2 id="dialog-title" tabindex="-1">¿Eliminar este proyecto?</h2>
<p>Esta acción no se puede deshacer. Exporta los datos si los necesitas.</p>
<div class="button-row">
<button id="cancel-dialog" type="button">Cancelar</button>
<button id="confirm-delete" class="danger" type="button">
Eliminar
</button>
</div>
</div>
</dialog>
<script>
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#confirm-dialog");
const title = document.querySelector("#dialog-title");
const cancelButton = document.querySelector("#cancel-dialog");
const confirmButton = document.querySelector("#confirm-delete");
openButton.addEventListener("click", () => {
dialog.showModal();
title.focus();
});
cancelButton.addEventListener("click", () => dialog.close("cancel"));
confirmButton.addEventListener("click", () => {
console.log("delete project");
dialog.close("confirm");
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
dialog.close("backdrop");
}
});
dialog.addEventListener("close", () => {
openButton.focus();
console.log(`closed by: ${dialog.returnValue || "unknown"}`);
});
</script>
</body>
</html>
Para abrir un modal, usa showModal() en lugar de añadir open a mano. Solo open puede dejar el fondo interactivo.
Componente React reutilizable
Este componente sirve para Vite, una SPA React o un Client Component de Next.js. En App Router, añade "use client"; al inicio.
import * as React from "react";
import "./modal-dialog.css";
type ModalDialogProps = {
open: boolean;
title: string;
description?: string;
closeOnBackdrop?: boolean;
onClose: () => void;
children: React.ReactNode;
footer: React.ReactNode;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function ModalDialog({
open,
title,
description,
closeOnBackdrop = true,
onClose,
children,
footer,
}: ModalDialogProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleRef = React.useRef<HTMLHeadingElement>(null);
const openerRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
dialog.showModal();
window.requestAnimationFrame(() => {
const preferred = dialog.querySelector<HTMLElement>("[data-autofocus]");
const firstFocusable = dialog.querySelector<HTMLElement>(
focusableSelector,
);
(preferred ?? firstFocusable ?? titleRef.current)?.focus();
});
}
if (!open && dialog.open) {
dialog.close();
}
}, [open]);
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleClose() {
onClose();
openerRef.current?.focus();
}
function handleClick(event: MouseEvent) {
if (event.target === dialog && closeOnBackdrop) {
onClose();
}
}
dialog.addEventListener("close", handleClose);
dialog.addEventListener("click", handleClick);
return () => {
dialog.removeEventListener("close", handleClose);
dialog.removeEventListener("click", handleClick);
};
}, [closeOnBackdrop, onClose]);
return (
<dialog
ref={dialogRef}
className="app-modal"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<div className="app-modal__body">
<div className="app-modal__header">
<h2 id={titleId} ref={titleRef} tabIndex={-1}>
{title}
</h2>
<button
type="button"
className="app-modal__icon"
aria-label="Cerrar diálogo"
onClick={onClose}
>
x
</button>
</div>
{description ? (
<p id={descriptionId} className="app-modal__description">
{description}
</p>
) : null}
<div className="app-modal__content">{children}</div>
<div className="app-modal__footer">{footer}</div>
</div>
</dialog>
);
}
.app-modal {
width: min(calc(100vw - 32px), 520px);
max-height: calc(100vh - 32px);
border: 0;
border-radius: 8px;
padding: 0;
color: #0f172a;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
.app-modal::backdrop {
background: rgb(15 23 42 / 0.58);
}
.app-modal__body {
display: grid;
gap: 16px;
padding: 24px;
}
.app-modal__header,
.app-modal__footer {
display: flex;
gap: 12px;
}
.app-modal__header {
align-items: flex-start;
justify-content: space-between;
}
.app-modal__footer {
flex-wrap: wrap;
justify-content: flex-end;
}
.app-modal__icon {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
@media (max-width: 480px) {
.app-modal__footer {
flex-direction: column-reverse;
}
.app-modal__footer button {
width: 100%;
}
}
Tres casos reales
| Caso | Por qué usar modal | Qué pedir a Claude Code |
|---|---|---|
| Eliminar, cancelar, cambiar rol | Es difícil de revertir | Texto de riesgo, bloqueo de doble envío, log |
| Invitar, facturación, ajuste corto | Se termina sin salir del contexto | Validación, estado pendiente, foco tras éxito |
| Paleta de comandos o búsqueda | Acción rápida sin navegar | Flechas, aria-activedescendant, estado vacío |
En acciones destructivas, decide si el clic exterior debe cerrar. En formularios cortos, no cierres si hay error: muestra el error dentro y hazlo anunciable.
Confirmación con Promise
import * as React from "react";
import { createRoot } from "react-dom/client";
import { ModalDialog } from "./ModalDialog";
type ConfirmDialogOptions = {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
};
export function confirmDialog(
options: ConfirmDialogOptions,
): Promise<boolean> {
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function finish(result: boolean) {
root.unmount();
container.remove();
resolve(result);
}
function ConfirmHost() {
return (
<ModalDialog
open
title={options.title}
description={options.message}
closeOnBackdrop={false}
onClose={() => finish(false)}
footer={
<>
<button type="button" onClick={() => finish(false)}>
{options.cancelLabel ?? "Cancelar"}
</button>
<button
type="button"
data-autofocus
className={options.danger ? "danger" : "primary"}
onClick={() => finish(true)}
>
{options.confirmLabel ?? "Confirmar"}
</button>
</>
}
>
<p>Revisa los detalles antes de continuar.</p>
</ModalDialog>
);
}
root.render(<ConfirmHost />);
});
}
Errores comunes
El primero es un botón de cierre que solo funciona con ratón. Usa button, añade nombre accesible y prueba Enter y Space.
El segundo es quitar el título por estética. El diálogo necesita un nombre accesible mediante aria-labelledby.
El tercero es eliminar el anillo de foco con outline: none. Sustitúyelo por un estilo visible con :focus-visible.
El cuarto es apilar modales. Complica el retorno de foco y el significado de Escape. Prefiere una confirmación clara o una opción de deshacer.
El quinto es olvidar móvil. Usa max-height, overflow: auto y revisa 320px.
Prueba mínima con Playwright
import { expect, test } from "@playwright/test";
test("modal opens, closes, and returns focus", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "Eliminar proyecto" });
await trigger.click();
const dialog = page.getByRole("dialog", {
name: "¿Eliminar este proyecto?",
});
await expect(dialog).toBeVisible();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
La revisión manual sigue siendo necesaria: usa solo teclado, luego NVDA o VoiceOver, y finalmente un viewport móvil estrecho.
CTA y monetización
Los modales suelen vivir cerca del ingreso: checkout, formularios de consulta, capturas de email y solicitudes de formación. Precisamente por eso hay que usarlos con cuidado. Un modal agresivo puede destruir confianza.
Para equipos que quieran ordenar Claude Code, CLAUDE.md, revisión accesible de UI y flujos React, está la formación y consultoría de Claude Code. Para trabajo individual, empieza por productos y la chuleta gratuita.
Resultado
Cuando Masa probó este patrón en una pantalla pequeña de ajustes React, lo que más redujo la revisión no fue la animación, sino escribir criterios claros: devolver foco al disparador, no cerrar acciones peligrosas con clic exterior y mantener botones usables a 320px.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.