← Componentes

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

Variant
Size
<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

ComponentePropTipoDefault
AlertDialogopenboolean
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
AlertDialogTriggerasChildbooleanfalse
AlertDialogContentvariantdefault | danger | warning | success | infodefault
sizesm | md | lgmd
AlertDialogActionvariantprimary | outline | ghost | success | danger | warning | infoprimary
AlertDialogCancelvariant— (mismo set que Button)outline
Content / Header / Title / Description / Footeratributos 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.