Diálogo modal de confirmación
Sobre <dialog> nativo: foco atrapado, Escape y clic fuera cierran, el foco vuelve al botón; acción destructiva con confirmación escrita.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
/** Diálogo modal de confirmación sobre <dialog> nativo: foco atrapado, Escape y clic fuera lo cierran, el foco vuelve al botón. Pide escribir el nombre para acciones destructivas. Minimalista. React + TypeScript, sin dependencias. */
export type CapaDialogoProps = {
trigger?: string;
title?: string;
description?: string;
/** Texto que hay que escribir para habilitar el botón destructivo. Vacío = sin confirmación escrita. */
confirmText?: string;
confirmLabel?: string;
onConfirm?: () => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gccdi-root { box-sizing: border-box; display: inline-block; font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #18181b; }
.gccdi-root *, .gccdi-root *::before, .gccdi-root *::after { box-sizing: border-box; }
.gccdi-root button, .gccdi-root input { font: inherit; }
.gccdi-root :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gccdi-dlg, .gccdi-dlg::backdrop { animation: none !important; } }
.gccdi-open { min-height: 44px; padding: 0 18px; border: 1px solid #fecaca; border-radius: 10px; background: #fff; color: #b91c1c; font-weight: 600; cursor: pointer; transition: background .15s; }
.gccdi-open:hover { background: #fef2f2; }
.gccdi-dlg { margin: auto; inset: 0; width: min(440px, calc(100vw - 32px)); padding: 0; border: 0; border-radius: 16px; background: #fff; color: #18181b; box-shadow: 0 30px 60px -20px rgba(0, 0, 0, .45), 0 0 0 1px rgba(0, 0, 0, .06); animation: gccdi-in .28s cubic-bezier(.16, 1, .3, 1); }
.gccdi-dlg::backdrop { background: rgba(10, 10, 14, .5); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: gccdi-fade .25s ease-out; }
.gccdi-body { padding: 24px 24px 8px; }
.gccdi-ico { display: grid; place-items: center; width: 44px; height: 44px; margin-bottom: 14px; border-radius: 50%; background: #fee2e2; color: #b91c1c; }
.gccdi-title { margin: 0 0 6px; font-size: 19px; font-weight: 700; letter-spacing: -.01em; }
.gccdi-desc { margin: 0 0 16px; color: #52525b; }
.gccdi-field label { display: block; margin-bottom: 6px; font-size: 13px; color: #52525b; }
.gccdi-field input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid #d4d4d8; border-radius: 8px; }
.gccdi-field input:focus-visible { outline-offset: 0; border-color: #2563eb; }
.gccdi-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 24px 20px; }
.gccdi-btn { min-height: 42px; padding: 0 18px; border: 1px solid #d4d4d8; border-radius: 10px; background: #fff; color: inherit; font-weight: 600; cursor: pointer; }
.gccdi-btn:hover { background: #f4f4f5; }
.gccdi-btn[data-d="true"] { border-color: #dc2626; background: #dc2626; color: #fff; }
.gccdi-btn[data-d="true"]:hover:not(:disabled) { background: #b91c1c; }
.gccdi-btn:disabled { opacity: .45; cursor: not-allowed; }
@keyframes gccdi-in { from { opacity: 0; transform: translateY(12px) scale(.97); } }
@keyframes gccdi-fade { from { opacity: 0; } }`;
export function CapaDialogo({ trigger = "Eliminar proyecto", title = "¿Eliminar este proyecto?", description = "Se borrarán sus tareas, archivos y comentarios. Esta acción no se puede deshacer.", confirmText = "mi-proyecto", confirmLabel = "Eliminar", onConfirm, className = "", style }: CapaDialogoProps) {
const id = useId();
const ref = useRef<HTMLDialogElement>(null);
const [text, setText] = useState("");
const ok = !confirmText || text === confirmText;
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
const onClose = () => setText("");
dlg.addEventListener("close", onClose);
return () => dlg.removeEventListener("close", onClose);
}, []);
return (
<div className={`gccdi-root ${className}`} style={style}>
<style>{CSS}</style>
<button className="gccdi-open" type="button" onClick={() => ref.current?.showModal()}>
{trigger}
</button>
<dialog ref={ref} className="gccdi-dlg" aria-labelledby={`${id}-t`} aria-describedby={`${id}-d`} onClick={(event) => event.target === ref.current && ref.current?.close()}>
<form method="dialog" onSubmit={(event) => { if (!ok) event.preventDefault(); else onConfirm?.(); }}>
<div className="gccdi-body">
<div className="gccdi-ico" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" /></svg>
</div>
<h2 className="gccdi-title" id={`${id}-t`}>{title}</h2>
<p className="gccdi-desc" id={`${id}-d`}>{description}</p>
{confirmText ? (
<div className="gccdi-field">
<label htmlFor={`${id}-i`}>Escribe <strong>{confirmText}</strong> para confirmar</label>
<input id={`${id}-i`} value={text} onChange={(event) => setText(event.target.value)} autoComplete="off" />
</div>
) : null}
</div>
<div className="gccdi-foot">
<button className="gccdi-btn" type="submit" formNoValidate value="cancel" onClick={(event) => { event.preventDefault(); ref.current?.close(); }}>Cancelar</button>
<button className="gccdi-btn" data-d="true" type="submit" disabled={!ok}>{confirmLabel}</button>
</div>
</form>
</dialog>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/capa-dialogoEjemplo de uso de Diálogo modal de confirmación
Después de copiar capa-dialogo.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { CapaDialogo } from "./capa-dialogo";
export default function Ejemplo() {
return <CapaDialogo trigger="Ejemplo" />;
}Propiedades de CapaDialogo
CapaDialogo acepta 8 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
trigger? | string | Opcional. |
title? | string | Opcional. |
description? | string | Opcional. |
confirmText? | string | Texto que hay que escribir para habilitar el botón destructivo. Vacío = sin confirmación escrita. |
confirmLabel? | string | Opcional. |
onConfirm? | () => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 88 líneas (5 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Diálogo modal de confirmación por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Usa el elemento <dialog> nativo, con foco y cierre con Escape gestionados por el navegador.
- Usa backdrop-filter para el efecto de vidrio; en navegadores sin soporte el panel se ve sin desenfoque.
- Es un componente de cliente de Next.js: incluye la directiva "use client".