Tips & Tricks (Actualizado: 2/6/2026)

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.

Crear modales accesibles con Claude Code: React, dialog y foco

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

CasoPor qué usar modalQué pedir a Claude Code
Eliminar, cancelar, cambiar rolEs difícil de revertirTexto de riesgo, bloqueo de doble envío, log
Invitar, facturación, ajuste cortoSe termina sin salir del contextoValidación, estado pendiente, foco tras éxito
Paleta de comandos o búsquedaAcción rápida sin navegarFlechas, 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.

#Claude Code #modal #dialogo #React #accesibilidad
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.