Destructive confirm that holds open while it works and explains failures in place.
01Preview
Shared files
3 files
q3-forecast.xlsx
Maya Chen · 2.4 MB
brand-guidelines-2026.pdf
Leo Park · 18 MB
onboarding-deck.key
You · 41 MB
Delete project
Removes stealth-web and every deploy
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import {
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from "@/components/ui/alert-dialog";
<AlertDialog>
<AlertDialogTrigger render={<Button variant="danger" />}>Delete…</AlertDialogTrigger>
<AlertDialogContent tone="danger">
<AlertDialogTitle>Delete q3-forecast.xlsx?</AlertDialogTitle>
<AlertDialogDescription>It’s removed for everyone. This can’t be undone.</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel />
<AlertDialogAction pendingLabel="Deleting…" onAction={() => deleteFile(id)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>04Source
"use client";
import { AlertDialog as BaseAlertDialog } from "@base-ui/react/alert-dialog";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
type Tone = "default" | "danger";
type Ctx = {
pending: boolean;
error: string | null;
cancelRef: React.RefObject<HTMLButtonElement | null>;
fieldRef: React.RefObject<HTMLInputElement | null>;
/** AlertDialogAction registers itself so Enter in the confirm field can run it. */
registerAction: (fn: (() => void) | null) => void;
submit: () => void;
armed: boolean;
setRequirement: (met: boolean | null) => void;
run: (fn: () => unknown, describe: (error: unknown) => string) => Promise<void>;
};
const AlertCtx = createContext<Ctx | null>(null);
const useAlert = () => {
const ctx = useContext(AlertCtx);
if (!ctx) throw new Error("AlertDialog parts must be inside <AlertDialog>");
return ctx;
};
const ToneCtx = createContext<Tone>("default");
export type AlertDialogProps = Omit<BaseAlertDialog.Root.Props, "open" | "defaultOpen" | "onOpenChange"> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
/**
* A confirm for the irreversible. It only closes by a choice (never an outside
* click), refuses to close while its action is running, and keeps the dialog
* open with the reason when the action fails.
*/
export function AlertDialog({ open: openProp, defaultOpen = false, onOpenChange, children, ...rest }: AlertDialogProps) {
const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [requirement, setRequirement] = useState<boolean | null>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const fieldRef = useRef<HTMLInputElement>(null);
const actionRef = useRef<(() => void) | null>(null);
const registerAction = (fn: (() => void) | null) => {
actionRef.current = fn;
};
const submit = () => actionRef.current?.();
const run: Ctx["run"] = async (fn, describe) => {
setError(null);
const result = fn();
// A plain function closes at once; a promise holds the dialog open until it settles.
if (!(result instanceof Promise)) return setOpen(false);
setPending(true);
try {
await result;
setPending(false);
setOpen(false);
} catch (e) {
setPending(false);
setError(describe(e));
}
};
return (
<AlertCtx.Provider
value={{ pending, error, cancelRef, fieldRef, registerAction, submit, armed: requirement !== false, setRequirement, run }}
>
<BaseAlertDialog.Root
open={open}
onOpenChange={(next, details) => {
if (!next && pending) {
details.cancel();
return;
}
if (next) setError(null);
setOpen(next);
}}
{...rest}
>
{children}
</BaseAlertDialog.Root>
</AlertCtx.Provider>
);
}
export type AlertDialogTriggerProps = BaseAlertDialog.Trigger.Props;
export function AlertDialogTrigger(props: AlertDialogTriggerProps) {
return <BaseAlertDialog.Trigger {...props} />;
}
export type AlertDialogContentProps = Omit<BaseAlertDialog.Popup.Props, "className"> & {
className?: string;
/** Danger turns the action red and the icon tile with it. */
tone?: Tone;
/** A 16px glyph shown in a tile above the title. */
icon?: React.ReactNode;
/** Render into this element instead of document.body. */
container?: BaseAlertDialog.Portal.Props["container"];
};
export function AlertDialogContent({ tone = "default", icon, container, className, children, ...rest }: AlertDialogContentProps) {
const { cancelRef, fieldRef } = useAlert();
const contained = container != null;
return (
<ToneCtx.Provider value={tone}>
<BaseAlertDialog.Portal container={container}>
<BaseAlertDialog.Backdrop
className={cn(
contained ? "absolute" : "fixed",
"inset-0 z-(--z-overlay) bg-overlay transition-opacity duration-200 ease-out-quart",
"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-150",
)}
/>
<BaseAlertDialog.Viewport
className={cn(
contained ? "absolute" : "fixed",
"inset-0 z-(--z-dialog) flex items-center justify-center p-4",
"max-sm:items-end max-sm:p-2 max-sm:pb-[max(8px,env(safe-area-inset-bottom))]",
)}
>
<BaseAlertDialog.Popup
// A typed confirmation is the safe place to start; otherwise Cancel, never the destructive verb.
initialFocus={() => fieldRef.current ?? cancelRef.current ?? true}
data-tone={tone}
className={cn(
"relative flex w-full max-w-[400px] flex-col rounded-2xl border border-line-2 bg-raised p-5 text-fg shadow-pop outline-none",
"origin-center transition-[opacity,scale,translate] duration-[260ms] ease-out-expo",
"data-starting-style:translate-y-1.5 data-starting-style:scale-[0.97] data-starting-style:opacity-0",
"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-[160ms] data-ending-style:ease-out-quart",
"max-sm:data-starting-style:translate-y-4 max-sm:data-starting-style:scale-100",
className,
)}
{...rest}
>
{icon && (
<span
aria-hidden
className={cn(
"mb-4 grid size-9 place-items-center rounded-full [&_svg]:size-4",
tone === "danger" ? "bg-danger-soft text-danger" : "bg-hover text-fg-2",
)}
>
{icon}
</span>
)}
{children}
</BaseAlertDialog.Popup>
</BaseAlertDialog.Viewport>
</BaseAlertDialog.Portal>
</ToneCtx.Provider>
);
}
export type AlertDialogTitleProps = Omit<BaseAlertDialog.Title.Props, "className"> & { className?: string };
export function AlertDialogTitle({ className, ...rest }: AlertDialogTitleProps) {
return (
<BaseAlertDialog.Title
className={cn("text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance", className)}
{...rest}
/>
);
}
export type AlertDialogDescriptionProps = Omit<BaseAlertDialog.Description.Props, "className"> & { className?: string };
export function AlertDialogDescription({ className, ...rest }: AlertDialogDescriptionProps) {
return <BaseAlertDialog.Description className={cn("mt-1.5 text-[13px] leading-[1.5] text-fg-2 text-pretty", className)} {...rest} />;
}
export type AlertDialogConfirmFieldProps = Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange"> & {
/** The exact text to type before the action unlocks, usually the name of the thing being deleted. */
match: string;
/** Label above the field. Defaults to “Type <match> to confirm”. */
label?: React.ReactNode;
};
/** Typed confirmation for the highest stakes: the action stays locked until the text matches. */
export function AlertDialogConfirmField({ match, label, className, ...rest }: AlertDialogConfirmFieldProps) {
const { fieldRef, setRequirement, submit, pending } = useAlert();
const [text, setText] = useState("");
const reduce = useReducedMotion();
const id = useId();
const met = text === match;
useEffect(() => {
setRequirement(met);
}, [met, setRequirement]);
useEffect(() => () => setRequirement(null), [setRequirement]);
return (
<div className="mt-4">
<label htmlFor={id} className="mb-1.5 block text-[12.5px] text-fg-2">
{label ?? (
<>
Type <span className="select-all rounded bg-hover px-1 py-px font-mono text-[12px] text-fg">{match}</span> to confirm
</>
)}
</label>
<div className="relative">
<input
ref={fieldRef}
id={id}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && met && !pending) {
e.preventDefault();
submit();
}
}}
readOnly={pending}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
enterKeyHint="done"
className={cn(
"h-9 w-full rounded-lg border border-line-2 bg-raised pl-3 pr-9 font-mono text-base text-fg shadow-[var(--shadow)] outline-none sm:text-[13px]",
"transition-[border-color,box-shadow] duration-150 ease-out-quart hover:border-fg-4 focus:border-fg-3 focus:ring-3 focus:ring-fg/8",
"read-only:opacity-60",
className,
)}
{...rest}
/>
{/* A tick draws in once the text matches: the unlock is visible before the button is. */}
<span aria-hidden className="pointer-events-none absolute inset-y-0 right-3 grid place-items-center text-success">
<AnimatePresence initial={false}>
{met && (
<motion.svg
key="tick"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}
>
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.28, ease: ease.out, delay: 0.04 }}
/>
</motion.svg>
)}
</AnimatePresence>
</span>
</div>
</div>
);
}
export type AlertDialogFooterProps = React.ComponentProps<"div">;
/** The actions, with the failure message opening above them when the action throws. */
export function AlertDialogFooter({ className, children, ...rest }: AlertDialogFooterProps) {
const { error } = useAlert();
return (
<div className="mt-5 flex flex-col">
<div
data-open={error ? "" : undefined}
className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-in-out-quart data-open:grid-rows-[1fr]"
>
<div className="min-h-0 overflow-hidden">
<p role="alert" className="mb-3 flex gap-2 rounded-lg bg-danger-soft px-3 py-2 text-[12.5px] leading-[1.45] text-danger">
{error && (
<>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" aria-hidden className="mt-px shrink-0">
<circle cx="8" cy="8" r="5.75" />
<path d="M8 5v3.5" />
<circle cx="8" cy="10.9" r=".6" fill="currentColor" stroke="none" />
</svg>
<span>{error}</span>
</>
)}
</p>
</div>
</div>
<div
className={cn(
"flex items-center justify-end gap-2",
"max-sm:grid max-sm:auto-cols-fr max-sm:grid-flow-col max-sm:*:h-10",
className,
)}
{...rest}
>
{children}
</div>
</div>
);
}
const base = cn(
"relative inline-flex h-8 shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg px-2.5 text-[12.5px] font-medium tracking-[-0.005em]",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,color,opacity,scale] duration-150 ease-out-quart active:scale-[0.97] active:duration-75",
);
export type AlertDialogCancelProps = Omit<BaseAlertDialog.Close.Props, "className"> & { className?: string };
/** Closes without acting. Receives focus on open, and is locked while the action runs. */
export function AlertDialogCancel({ className, disabled, children = "Cancel", ...rest }: AlertDialogCancelProps) {
const { cancelRef, pending } = useAlert();
return (
<BaseAlertDialog.Close
ref={cancelRef}
disabled={disabled || pending}
className={cn(
base,
"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
{...rest}
>
{children}
</BaseAlertDialog.Close>
);
}
export type AlertDialogActionProps = Omit<React.ComponentProps<"button">, "onClick" | "children"> & {
/** The verb: “Delete”, “Revoke”, “Remove member”. Never “OK” or “Yes”. */
children: string;
/** Runs on press. Return a promise to hold the dialog open, busy, until it settles. */
onAction?: () => void | Promise<unknown>;
/** Shown with a spinner while the promise runs, e.g. “Deleting…”. */
pendingLabel?: string;
/** Turns a rejection into the message shown above the buttons. */
errorMessage?: (error: unknown) => string;
};
export function AlertDialogAction({
children,
onAction,
pendingLabel,
errorMessage = () => "That didn’t go through. Try again.",
disabled,
className,
...rest
}: AlertDialogActionProps) {
const { run, pending, armed, registerAction } = useAlert();
const tone = useContext(ToneCtx);
const reduce = useReducedMotion();
const busyLabel = pendingLabel ?? `${children}…`;
const locked = disabled || !armed;
const fire = () => {
if (pending || locked) return;
void run(() => onAction?.(), errorMessage);
};
useEffect(() => {
registerAction(fire);
return () => registerAction(null);
});
const t = { duration: reduce ? 0.12 : 0.2, ease: ease.out };
return (
<button
type="button"
data-tone={tone}
aria-busy={pending || undefined}
aria-disabled={locked || undefined}
disabled={locked}
onClick={fire}
className={cn(
base,
"disabled:pointer-events-none disabled:opacity-45 aria-busy:cursor-progress aria-busy:active:scale-100",
tone === "danger"
? "bg-danger text-frame shadow-[var(--shadow)] hover:bg-danger/90"
: "bg-fg text-frame shadow-[var(--shadow)] hover:bg-fg/90",
className,
)}
{...rest}
>
{/* Both labels share one grid cell, so the button is as wide as the longer and never jumps. */}
<span className="grid overflow-hidden py-1">
<span aria-hidden className="invisible col-start-1 row-start-1">{children}</span>
<span aria-hidden className="invisible col-start-1 row-start-1 pl-[22px]">{busyLabel}</span>
<AnimatePresence initial={false}>
<motion.span
key={pending ? "busy" : "idle"}
className="col-start-1 row-start-1 flex items-center justify-center gap-1.5"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }}
transition={t}
>
{pending && <Spinner />}
{pending ? busyLabel : children}
</motion.span>
</AnimatePresence>
</span>
<span role="status" className="sr-only">
{pending ? busyLabel : ""}
</span>
</button>
);
}
function Spinner() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden className="shrink-0 animate-[spin_0.7s_linear_infinite]">
<circle cx="8" cy="8" r="5.75" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
<path d="M8 2.25A5.75 5.75 0 0 1 13.75 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}05Props
AlertDialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called on open and close, including the close after a successful action. |
AlertDialogContent
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "default" | "danger" | "default" | Danger paints the action and the icon tile red. |
| icon | ReactNode | — | A 16px glyph shown in a round tile above the title. |
| container | HTMLElement | RefObject<HTMLElement> | — | Portal target instead of document.body. |
AlertDialogTitle
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Name the object: “Delete q3-forecast.xlsx?”, never “Are you sure?”. |
AlertDialogDescription
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | The consequence, in one or two lines. |
AlertDialogConfirmField
| Prop | Type | Default | Description |
|---|---|---|---|
| match* | string | — | Text that must be typed before the action unlocks. |
| label | ReactNode | “Type <match> to confirm” | Label above the field. |
AlertDialogFooter
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | The buttons. The failure message opens above them when the action throws. |
AlertDialogCancel
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | "Cancel" | Closes without acting. Focused on open; locked while the action runs. |
AlertDialogAction
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | string | — | The verb: Delete, Revoke, Remove member. |
| onAction | () => void | Promise<unknown> | — | Runs on press. A promise keeps the dialog open and busy until it settles; a rejection keeps it open with the error. |
| pendingLabel | string | `${children}…` | Label beside the spinner while the promise runs. |
| errorMessage | (error: unknown) => string | “That didn’t go through. Try again.” | What the failure row says. |
| disabled | boolean | false | Lock the action for your own reasons. |
06Notes
Behavior
- It never closes on an outside click. While the action runs, Escape and Cancel are refused, so a half-finished delete can't be walked away from.
- A rejected promise keeps the dialog open: the button returns to its verb and a message opens above the footer saying what happened. Pressing again clears it.
- With a confirm field the action stays locked until the text matches exactly; Enter in the field then runs it.
- The idle and busy labels share one grid cell, so “Delete” → “Deleting…” never changes the button's width.
Motion
- Opens like Dialog: scale .97 → 1 with a 6px rise, 260ms expo ease-out; closes in 160ms with no travel.
- The busy label arrives from 8px below with a 2px blur while the idle one leaves upward, 200ms. The error row grows with grid-template-rows over 200ms on the in-out curve.
- When the confirm text matches, a tick draws in 280ms and the locked button fades up to full strength.
- Reduced motion keeps short crossfades and drops travel, blur and the path draw.
Accessibility
- role=alertdialog, labeled by the title and described by the description; focus is trapped and returns to the trigger.
- Focus lands on Cancel (or the confirm field when there is one), never on the destructive verb.
- The busy label and the failure message are announced through a status region and role=alert.