# Alert Dialog

A confirm dialog that needs an answer: Cancel takes focus first, clicking outside shakes the panel instead of closing it, and async confirms show a spinner.

**Category:** Overlays  
**Exports:** `AlertDialog`  
**npm dependencies:** motion, lucide-react (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/alert-dialog.json
```
The file lands at `components/ui/alert-dialog.tsx`; import from `@/components/ui/alert-dialog`.

## API — Alert Dialog
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | `boolean` | — |  |
| `onOpenChange` | `(open: boolean) => void` | — |  |
| `title / description` | `ReactNode` | — |  |
| `confirmLabel / cancelLabel` | `string` | — |  |
| `destructive` | `boolean` | `false` |  |
| `onConfirm` | `() => void \| Promise<void>` | — |  |
| `className` | `string` | — |  |

## Example — Alert Dialog

```tsx
import * as React from "react";
import { AlertDialog } from "@/components/ui/alert-dialog";

export default function AlertDialogDemo({ destructive = true }: { destructive?: boolean }) {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <button type="button" onClick={() => setOpen(true)} className="inline-flex h-10 cursor-pointer items-center rounded-lg border px-4 text-sm font-medium hover:bg-accent">
        Delete project
      </button>
      <AlertDialog
        open={open}
        onOpenChange={setOpen}
        destructive={destructive}
        title="Delete this project?"
        description="All 24 pages, their history and the custom domain will be removed. This can't be undone."
        confirmLabel="Delete project"
        onConfirm={() => new Promise((r) => setTimeout(r, 1200))}
      />
    </>
  );
}
```

## Source — components/ui/alert-dialog.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import { TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";

export type AlertDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: React.ReactNode;
  description?: React.ReactNode;
  confirmLabel?: string;
  cancelLabel?: string;
  /** Styles the confirm button as destructive. */
  destructive?: boolean;
  /** Return a promise to keep the dialog open, with a spinner, until it settles. */
  onConfirm?: () => void | Promise<void>;
  className?: string;
};

/** A confirm dialog that can't be dismissed by clicking outside: Cancel takes focus first, the panel shakes if you click the backdrop, and async confirms show a spinner. */
export function AlertDialog({ open, onOpenChange, title, description, confirmLabel = "Continue", cancelLabel = "Cancel", destructive = false, onConfirm, className }: AlertDialogProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDialogElement>(null);
  const cancel = React.useRef<HTMLButtonElement>(null);
  const [mounted, setMounted] = React.useState(open);
  const [busy, setBusy] = React.useState(false);
  const [scope, animate] = useAnimate();
  if (open && !mounted) setMounted(true);

  React.useEffect(() => {
    const d = ref.current;
    if (open && d && !d.open) {
      d.showModal();
      cancel.current?.focus();
    }
  }, [open, mounted]);

  const confirm = async () => {
    setBusy(true);
    try {
      await onConfirm?.();
      onOpenChange(false);
    } finally {
      setBusy(false);
    }
  };

  if (!mounted) return null;
  return (
    <dialog
      ref={ref}
      role="alertdialog"
      aria-labelledby={`${id}-t`}
      aria-describedby={description ? `${id}-d` : undefined}
      onCancel={(e) => {
        e.preventDefault();
        if (!busy) onOpenChange(false);
      }}
      className="fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-transparent"
    >
      <AnimatePresence
        onExitComplete={() => {
          ref.current?.close();
          setMounted(false);
        }}
      >
        {open && (
          <motion.div key="layer" className="absolute inset-0 grid place-items-center p-4">
            <motion.div className="absolute inset-0 bg-background/70 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => !reduce && scope.current && animate(scope.current, { x: [0, -8, 7, -4, 2, 0] }, { duration: 0.36 })} />
            <motion.div
              initial={{ opacity: 0, scale: reduce ? 1 : 0.95, y: reduce ? 0 : 10 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: reduce ? 1 : 0.97, transition: { duration: 0.14 } }}
              transition={{ type: "spring", stiffness: 420, damping: 32 }}
              className={cn("relative w-full max-w-sm rounded-2xl border bg-popover p-6 text-popover-foreground shadow-2xl", className)}
            >
              {/* A backdrop click shakes the panel: this dialog needs an answer. */}
              <div ref={scope}>
                <span className={cn("grid size-10 place-items-center rounded-full", destructive ? "bg-destructive/15 text-destructive" : "bg-ember/15 text-ember")}>
                  <TriangleAlert className="size-5" />
                </span>
                <h2 id={`${id}-t`} className="mt-4 text-lg font-semibold tracking-tight">
                  {title}
                </h2>
                {description && (
                  <p id={`${id}-d`} className="mt-1.5 text-sm leading-6 text-muted-foreground">
                    {description}
                  </p>
                )}
                <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
                  <button ref={cancel} type="button" disabled={busy} onClick={() => onOpenChange(false)} className="inline-flex h-9 cursor-pointer items-center justify-center rounded-lg border px-4 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
                    {cancelLabel}
                  </button>
                  <button type="button" disabled={busy} onClick={confirm} className={cn("inline-flex h-9 min-w-24 cursor-pointer items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait", destructive ? "bg-destructive text-destructive-foreground" : "bg-foreground text-background")}>
                    {busy && <span aria-hidden className="size-3.5 animate-spin rounded-full border-2 border-current border-r-transparent" />}
                    {confirmLabel}
                  </button>
                </div>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </dialog>
  );
}
```