Alert Dialog
Interrumpe para pedir una decisión explícita — eliminar, confirmar, descartar. A diferencia de Alert (que informa sin bloquear), esto exige una respuesta: no se cierra clickeando afuera, solo con Cancelar, Esc o la acción misma.
Playground
<AlertDialogContent variant="danger">
<AlertDialogHeader>
<TrashIcon />
<AlertDialogTitle>¿Eliminar la plantilla?</AlertDialogTitle>
<AlertDialogDescription>Esta acción no se puede deshacer.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction variant="danger">Eliminar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>El gesto de apertura
Obsidiana plana al 80% como fondo — sin blur, nada de glassmorphism. El panel entra con una curva sin rebote (opacidad + escala + un ligero desplazamiento), y sus cuatro marcas de esquina se cierran sobre él un instante después, como si engastaran la pieza. La salida es más simple y más rápida que la entrada — vuelve a ser fondo, no el protagonista.
Variantes
variant en AlertDialogContent tiñe las marcas de esquina y, si pones un <svg> como primer hijo de AlertDialogHeader, también el badge del ícono — mismo patrón [&>svg] que usa Alert, aquí como badge cuadrado en vez de inline. El default sin variante queda en dorado, sin ícono.
danger
warning
success
info
Tamaños
size en AlertDialogContent — sm / md / lg, controla el ancho del panel. md es el default y alcanza para la gran mayoría de confirmaciones.
Código
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { useDialogTransition } from "./lib/use-dialog-transition";
import { Button, type ButtonProps } from "./button";
// debe calzar con la duración de cierre (duration-200) de abajo — el
// portal se desmonta justo cuando la transición de salida termina, ni
// antes (corte visible) ni después (parpadeo de contenido invisible)
const EXIT_MS = 200;
type AlertDialogContextValue = { open: boolean; setOpen: (open: boolean) => void };
const AlertDialogContext = React.createContext<AlertDialogContextValue | null>(null);
function useAlertDialogContext(component: string) {
const ctx = React.useContext(AlertDialogContext);
if (!ctx) throw new Error(`${component} debe usarse dentro de AlertDialog`);
return ctx;
}
export type AlertDialogVariant = "default" | "danger" | "warning" | "success" | "info";
export type AlertDialogSize = "sm" | "md" | "lg";
const AlertDialogContentContext = React.createContext<{
titleId: string;
descriptionId: string;
variant: AlertDialogVariant;
} | null>(null);
// acento en las marcas de esquina — el mismo token que Alert usa para su
// borde, leído distinto (línea de esquina en vez de borde completo)
const cornerVariants: Record<AlertDialogVariant, string> = {
default: "border-primary/70",
danger: "border-danger/70",
warning: "border-warning/70",
success: "border-success/70",
info: "border-info/70",
};
// ícono opcional en el header — mismo patrón que Alert ([&>svg] en vez de
// una prop icon), aquí como badge cuadrado en vez de inline con el texto
const iconVariants: Record<AlertDialogVariant, string> = {
default: "[&>svg]:bg-primary/10 [&>svg]:text-primary",
danger: "[&>svg]:bg-danger/10 [&>svg]:text-danger",
warning: "[&>svg]:bg-warning/10 [&>svg]:text-warning",
success: "[&>svg]:bg-success/10 [&>svg]:text-success",
info: "[&>svg]:bg-info/10 [&>svg]:text-info",
};
const sizeVariants: Record<AlertDialogSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
};
export interface AlertDialogProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
export function AlertDialog({ open: controlledOpen, defaultOpen = false, onOpenChange, children }: AlertDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const value = React.useMemo(() => ({ open, setOpen }), [open, setOpen]);
return <AlertDialogContext.Provider value={value}>{children}</AlertDialogContext.Provider>;
}
export interface AlertDialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** clona el hijo en vez de envolverlo en un <button> propio — para usar Button como trigger sin anidar botones */
asChild?: boolean;
}
export const AlertDialogTrigger = React.forwardRef<HTMLButtonElement, AlertDialogTriggerProps>(
({ asChild = false, children, onClick, ...props }, ref) => {
const { setOpen } = useAlertDialogContext("AlertDialogTrigger");
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{ onClick?: React.MouseEventHandler }>;
return React.cloneElement(child, {
onClick: (e: React.MouseEvent) => {
child.props.onClick?.(e);
setOpen(true);
},
});
}
return (
<button
ref={ref}
type="button"
onClick={(e) => {
onClick?.(e);
setOpen(true);
}}
{...props}
>
{children}
</button>
);
},
);
AlertDialogTrigger.displayName = "AlertDialogTrigger";
const corners = [
{ position: "-left-2 -top-2", border: "border-l border-t", offset: "translate(-6px,-6px)" },
{ position: "-right-2 -top-2", border: "border-r border-t", offset: "translate(6px,-6px)" },
{ position: "-left-2 -bottom-2", border: "border-l border-b", offset: "translate(-6px,6px)" },
{ position: "-right-2 -bottom-2", border: "border-r border-b", offset: "translate(6px,6px)" },
] as const;
export interface AlertDialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
/** acento de las marcas de esquina + tinte del ícono opcional del header */
variant?: AlertDialogVariant;
/** ancho del panel */
size?: AlertDialogSize;
}
export const AlertDialogContent = React.forwardRef<HTMLDivElement, AlertDialogContentProps>(
({ className, children, variant = "default", size = "md", ...props }, ref) => {
const { open, setOpen } = useAlertDialogContext("AlertDialogContent");
const { portalReady, rendered, entered, panelRef } = useDialogTransition(open, setOpen, EXIT_MS);
const titleId = React.useId();
const descriptionId = React.useId();
if (!portalReady || !rendered) return null;
return createPortal(
<AlertDialogContentContext.Provider value={{ titleId, descriptionId, variant }}>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* obsidiana plana, sin blur — nada de glassmorphism. sin onClick
a propósito: un alert dialog exige una respuesta explícita,
no se descarta clickeando afuera (a diferencia de Dialog) */}
<div
aria-hidden="true"
className={cn(
"absolute inset-0 bg-background/80 transition-opacity",
EASE,
entered ? "opacity-100 duration-300" : "opacity-0 duration-200",
)}
/>
<div
ref={(node) => {
panelRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className={cn(
"relative w-full rounded-md border border-border bg-background p-6 transition-all",
EASE,
sizeVariants[size],
entered ? "opacity-100 scale-100 translate-y-0 duration-300" : "opacity-0 scale-95 translate-y-2 duration-200",
className,
)}
{...props}
>
{/* marcas de esquina — la misma idea de las cards (hairline que
aparece en las esquinas), aquí como el gesto de apertura: se
cierran sobre el panel un instante después de que aparece,
como si engastaran la pieza */}
{corners.map((corner) => (
<span
key={corner.position}
aria-hidden="true"
className={cn(
"pointer-events-none absolute h-4 w-4 transition-all",
EASE,
corner.position,
corner.border,
cornerVariants[variant],
entered ? "opacity-100 duration-300 delay-150" : "opacity-0 duration-200",
)}
style={{ transform: entered ? "translate(0,0)" : corner.offset }}
/>
))}
{children}
</div>
</div>
</AlertDialogContentContext.Provider>,
document.body,
);
},
);
AlertDialogContent.displayName = "AlertDialogContent";
export const AlertDialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const ctx = React.useContext(AlertDialogContentContext);
return (
<div
ref={ref}
className={cn(
// un <svg> hijo directo se vuelve badge de ícono — mismo patrón
// que Alert, aquí cuadrado y arriba del título en vez de inline
"flex flex-col gap-1.5 [&>svg]:mb-1 [&>svg]:h-9 [&>svg]:w-9 [&>svg]:rounded-md [&>svg]:p-2",
iconVariants[ctx?.variant ?? "default"],
className,
)}
{...props}
/>
);
},
);
AlertDialogHeader.displayName = "AlertDialogHeader";
export const AlertDialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, id, ...props }, ref) => {
const ctx = React.useContext(AlertDialogContentContext);
return <h2 ref={ref} id={id ?? ctx?.titleId} className={cn("text-lg font-semibold", className)} {...props} />;
},
);
AlertDialogTitle.displayName = "AlertDialogTitle";
export const AlertDialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, id, ...props }, ref) => {
const ctx = React.useContext(AlertDialogContentContext);
return (
<p ref={ref} id={id ?? ctx?.descriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />
);
},
);
AlertDialogDescription.displayName = "AlertDialogDescription";
export const AlertDialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("mt-6 flex justify-end gap-3", className)} {...props} />
),
);
AlertDialogFooter.displayName = "AlertDialogFooter";
export interface AlertDialogActionProps extends ButtonProps {}
export const AlertDialogAction = React.forwardRef<HTMLButtonElement, AlertDialogActionProps>(
({ onClick, variant = "primary", ...props }, ref) => {
const { setOpen } = useAlertDialogContext("AlertDialogAction");
return (
<Button
ref={ref}
variant={variant}
onClick={(e) => {
onClick?.(e);
setOpen(false);
}}
{...props}
/>
);
},
);
AlertDialogAction.displayName = "AlertDialogAction";
export interface AlertDialogCancelProps extends ButtonProps {}
export const AlertDialogCancel = React.forwardRef<HTMLButtonElement, AlertDialogCancelProps>(
({ onClick, variant = "outline", ...props }, ref) => {
const { setOpen } = useAlertDialogContext("AlertDialogCancel");
return (
<Button
ref={ref}
variant={variant}
onClick={(e) => {
onClick?.(e);
setOpen(false);
}}
{...props}
/>
);
},
);
AlertDialogCancel.displayName = "AlertDialogCancel";Código real de packages/ui/src/alert-dialog.tsx.
Abrir sin Trigger
open / onOpenChange lo vuelven controlado — útil cuando lo que lo abre no es un click directo (una respuesta de servidor, un ítem de menú).
const [open, setOpen] = useState(false);
<AlertDialog open={open} onOpenChange={setOpen}>
{/* sin Trigger — se abre desde donde quieras: un ítem de menú, una respuesta de fetch */}
<AlertDialogContent>{/* … */}</AlertDialogContent>
</AlertDialog>Uso
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "./components/ui/alert-dialog";
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="danger">Eliminar plantilla</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar la plantilla?</AlertDialogTitle>
<AlertDialogDescription>Esta acción no se puede deshacer.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction variant="danger">Eliminar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| AlertDialog | open | boolean | — |
| defaultOpen | boolean | false | |
| onOpenChange | (open: boolean) => void | — | |
| AlertDialogTrigger | asChild | boolean | false |
| AlertDialogContent | variant | default | danger | warning | success | info | default |
| size | sm | md | lg | md | |
| AlertDialogAction | variant | primary | outline | ghost | success | danger | warning | info | primary |
| AlertDialogCancel | variant | — (mismo set que Button) | outline |
| Content / Header / Title / Description / Footer | — | atributos nativos de su elemento | |
AlertDialogAction y AlertDialogCancel son Button por dentro — cierran el diálogo al hacer click y aceptan cualquier variant suya, no un set de colores propio.