Ir al contenido
Evaluar mi proyecto
Componentes · Modales y capasDisponible

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.

¿Eliminar este proyecto?

Se borrarán sus tareas, archivos y comentarios. Esta acción no se puede deshacer.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

capa-dialogo.tsx88 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/capa-dialogo

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
trigger?stringOpcional.
title?stringOpcional.
description?stringOpcional.
confirmText?stringTexto que hay que escribir para habilitar el botón destructivo. Vacío = sin confirmación escrita.
confirmLabel?stringOpcional.
onConfirm?() => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)