Modal
Un diálogo superpuesto que aparece encima del contenido de la página para captar la atención del usuario en confirmaciones, formularios o información importante.
Casos de uso
- Confirmar acciones destructivas (eliminar cuenta, eliminar elemento)
- Mostrar formularios sin navegar (editar perfil, nuevo elemento)
- Mostrar vistas previas detalladas (lightbox de imagen, visor de documentos)
- Alertar a los usuarios sobre información crítica (vencimiento de sesión, errores)
- Recopilar entrada rápida (renombrar, agregar una etiqueta, dejar un comentario)
- Mostrar términos de servicio o diálogos de consentimiento
Implementación más simple
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
} else {
dialog.close();
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
{children}
</dialog>
);
}Utiliza el elemento nativo <dialog> que proporciona telón de fondo incorporado, trampa de enfoque y manejo de la tecla Escape. El modificador backdrop: de Tailwind diseña el overlay detrás del diálogo.
Variaciones
Con título y botón de cierre
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-md rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
>
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Cerrar"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-4">{children}</div>
</dialog>
);
}Separa el encabezado y el cuerpo con un borde. El botón de cierre utiliza aria-label para accesibilidad ya que solo contiene un icono.
Modal de confirmación
"use client";
import { useEffect, useRef } from "react";
interface ConfirmModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
loading?: boolean;
}
export function ConfirmModal({
open, onClose, onConfirm, title, message, confirmLabel = "Confirmar", loading,
}: ConfirmModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-gray-600">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Cancelar
</button>
<button
onClick={onConfirm}
disabled={loading}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{loading ? "Eliminando..." : confirmLabel}
</button>
</div>
</dialog>
);
}Un diálogo de confirmación construido con propósito específico. El botón de acción destructiva es rojo y el botón de cancelar es visualmente más claro para guiar al usuario hacia la opción segura.
Modal con acciones de pie de página
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer: React.ReactNode;
}
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-lg rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
>
<div className="border-b px-6 py-4">
<h2 className="text-lg font-semibold">{title}</h2>
</div>
<div className="px-6 py-4">{children}</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">{footer}</div>
</dialog>
);
}Un diseño basado en espacios con footer como prop. Esto permite que el componente padre proporcione cualquier combinación de botones sin que el modal necesite conocer acciones específicas.
Modal animado
"use client";
import { useEffect, useRef, useState } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
requestAnimationFrame(() => setVisible(true));
} else {
setVisible(false);
const timer = setTimeout(() => dialog.close(), 200);
return () => clearTimeout(timer);
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className={`w-full max-w-md rounded-xl bg-white p-6 shadow-xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
visible ? "scale-100 opacity-100 backdrop:opacity-100" : "scale-95 opacity-0 backdrop:opacity-0"
}`}
>
{children}
</dialog>
);
}Un enfoque de dos fases: showModal() hace que el diálogo sea visible en el DOM, luego requestAnimationFrame activa la transición CSS. Al cerrar, la animación se reproduce antes de que dialog.close() lo elimine.
Evitar cerrar al hacer clic en el telón de fondo
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
closeOnBackdrop?: boolean;
children: React.ReactNode;
}
export function Modal({ open, onClose, closeOnBackdrop = true, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
function handleClick(e: React.MouseEvent<HTMLDialogElement>) {
if (!closeOnBackdrop) return;
const rect = dialogRef.current?.getBoundingClientRect();
if (!rect) return;
const clickedOutside =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (clickedOutside) onClose();
}
return (
<dialog
ref={dialogRef}
onClose={onClose}
onClick={handleClick}
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
{children}
</dialog>
);
}El nativo <dialog> no se cierra al hacer clic en el telón de fondo por defecto con showModal(). Esto verifica si las coordenadas del clic están fuera del rectángulo delimitador del diálogo para simular el comportamiento de cerrar con clic en el telón de fondo.
Implementación compleja
"use client";
import { useEffect, useRef, useState, useCallback, createContext, useContext } from "react";
import { createPortal } from "react-dom";
// --- Contexto para acceso anidado ---
interface ModalContextValue {
close: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModal debe usarse dentro de un Modal");
return ctx;
}
// --- Componente Modal ---
interface ModalProps {
open: boolean;
onClose: () => void;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
}
export function Modal({
open,
onClose,
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef,
children,
}: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [visible, setVisible] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
requestAnimationFrame(() => {
setVisible(true);
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
}
});
} else {
setVisible(false);
const timer = setTimeout(() => dialog.close(), 200);
return () => clearTimeout(timer);
}
}, [open, initialFocusRef]);
const handleCancel = useCallback(
(e: React.SyntheticEvent) => {
if (!closeOnEscape) {
e.preventDefault();
return;
}
onClose();
},
[closeOnEscape, onClose]
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (!closeOnBackdrop) return;
const rect = dialogRef.current?.getBoundingClientRect();
if (!rect) return;
const outside =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (outside) onClose();
},
[closeOnBackdrop, onClose]
);
// Bloquear scroll del body cuando está abierto
useEffect(() => {
if (open) {
const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
return () => {
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY);
};
}
}, [open]);
if (!mounted) return null;
return createPortal(
<ModalContext.Provider value={{ close: onClose }}>
<dialog
ref={dialogRef}
onCancel={handleCancel}
onClose={onClose}
onClick={handleClick}
className={`w-full max-w-lg rounded-xl bg-white p-0 shadow-2xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
visible
? "translate-y-0 scale-100 opacity-100 backdrop:opacity-100"
: "translate-y-2 scale-95 opacity-0 backdrop:opacity-0"
}`}
aria-modal="true"
>
{children}
</dialog>
</ModalContext.Provider>,
document.body
);
}
// --- Partes compuestas ---
export function ModalHeader({ children }: { children: React.ReactNode }) {
const { close } = useModal();
return (
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{children}</h2>
<button
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
export function ModalBody({ children }: { children: React.ReactNode }) {
return <div className="px-6 py-4">{children}</div>;
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
return <div className="flex justify-end gap-3 border-t px-6 py-4">{children}</div>;
}Aspectos clave:
<dialog>nativo conshowModal()— proporciona trampa de enfoque incorporada, manejo de la tecla Escape y semánticaaria-modal. No es necesario implementar una trampa de enfoque personalizada.- Patrón de componente compuesto —
ModalHeader,ModalBody,ModalFooteracceden a la función de cierre a través del contexto, manteniendo la API componible. - Bloqueo de scroll del body — fija la posición del body mientras se preserva la posición de desplazamiento. Al cerrar, restaura el desplazamiento exacto para que el usuario no pierda su lugar.
- Animación con
requestAnimationFrame— el diálogo se muestra primero, luego se anima en el siguiente fotograma. Al cerrar, la animación se reproduce durante 200ms antes de que se llame adialog.close(). - Intercepción de
onCancel— el diálogo nativo dispara un eventocancelen Escape. CuandocloseOnEscapees falso,preventDefault()lo bloquea. - Portal mediante
createPortal— renderiza el diálogo endocument.bodypara evitar problemas de contexto de apilamiento z-index de componentes padre. initialFocusRef— permite que el llamador especifique qué elemento debe recibir el enfoque cuando se abre el modal (p. ej., un campo de entrada específico).
Trampas
-
Olvidar
showModal()vsshow()—show()abre el diálogo sin telón de fondo y sin trampa de enfoque. Siempre usashowModal()para diálogos modales. -
El clic en el telón de fondo no cierra por defecto — A diferencia de muchas bibliotecas, el nativo
<dialog>conshowModal()no se cierra al hacer clic en el telón de fondo. Debes implementar esto por ti mismo verificando las coordenadas del clic. -
Desbordamiento de scroll — La página detrás del modal aún puede desplazarse en Safari móvil. Utiliza el patrón de bloqueo de scroll del body (posición fija + desplazamiento guardado) para evitar esto.
-
Modales anidados — Abrir un segundo
<dialog>mientras uno ya está abierto puede causar conflictos en la trampa de enfoque. Evita apilar modales; utiliza una hoja o expansión en línea en su lugar. -
Manejador
onClosefaltante — Si no establecesonClose, presionar Escape cierra el diálogo visualmente pero tu estado React sigue siendoopen: true, causando una desincronización. Siempre sincroniza la devolución de llamadaonClose. -
Animación en el primer montaje — Si el diálogo está abierto en el montaje, la animación se reproduce desde el estado inicial. Utiliza
requestAnimationFrameo una banderamountedpara omitir la animación de entrada cuando sea apropiado.