Confirm dialog
Asks one question before an action, with a pending state for async confirms.
npx shadcn@latest add https://ui.goodtabs.co/r/confirm-dialog.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/confirm-dialog
Wraps any trigger. Confirm closes the dialog straight away.
Destructive and async
The confirm button spins and both buttons lock until the promise settles.
Controlled
Nothing yet
Open it from anywhere with open and onOpenChange, no trigger needed.
Usage
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
<ConfirmDialog
variant="destructive"
title="Delete this project?"
description="There is no undo."
confirmLabel="Delete"
onConfirm={async () => { await deleteProject(id) }}
>
<Button variant="destructive">Delete</Button>
</ConfirmDialog>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | The trigger element, rendered with asChild. | |
| title | string | The question, as a short sentence. | |
| description | string | What happens if they confirm. | |
| confirmLabel | string | "Confirm" | Label of the confirm button. |
| cancelLabel | string | "Cancel" | Label of the cancel button. |
| variant | "default" | "destructive" | "default" | Destructive styles the confirm button for actions with no undo. |
| onConfirm | () => void | Promise<void> | Runs on confirm. A promise shows a pending state until it settles. | |
| open | boolean | Controlled open state. | |
| onOpenChange | (open: boolean) => void | Fires when the dialog asks to open or close. |
Source
confirm-dialog/confirm-dialog.tsx
"use client";
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
type ConfirmDialogProps = {
/** The element that opens the dialog, usually a Button. Rendered with asChild. */
children?: React.ReactNode;
/** The question, as a short sentence. */
title: string;
/** What happens if they confirm. */
description?: string;
/** Label of the confirm button. */
confirmLabel?: string;
/** Label of the cancel button. */
cancelLabel?: string;
/** "destructive" styles the confirm button for actions that cannot be undone. */
variant?: "default" | "destructive";
/** Runs on confirm. Return a promise to show a pending state until it settles. */
onConfirm: () => void | Promise<void>;
/** Controlled open state. Leave out to let the trigger manage it. */
open?: boolean;
/** Fires when the dialog asks to open or close. */
onOpenChange?: (open: boolean) => void;
};
/**
* An alert dialog that asks one question and waits for the answer.
* Confirm can be async: the button shows a spinner and both buttons lock until it settles.
*/
export function ConfirmDialog({
children,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
onConfirm,
open,
onOpenChange,
}: ConfirmDialogProps) {
const [innerOpen, setInnerOpen] = React.useState(false);
const [pending, setPending] = React.useState(false);
const controlled = open !== undefined;
const isOpen = controlled ? open : innerOpen;
const setOpen = (next: boolean) => {
if (pending) return;
if (!controlled) setInnerOpen(next);
onOpenChange?.(next);
};
const confirm = async (e: React.MouseEvent) => {
e.preventDefault();
try {
setPending(true);
await onConfirm();
if (!controlled) setInnerOpen(false);
onOpenChange?.(false);
} finally {
setPending(false);
}
};
return (
<AlertDialog open={isOpen} onOpenChange={setOpen}>
{children && <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>}
<AlertDialogContent onEscapeKeyDown={(e) => pending && e.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description && <AlertDialogDescription>{description}</AlertDialogDescription>}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
<Button
type="button"
variant={variant === "destructive" ? "destructive" : "default"}
className={variant === "destructive" ? "bg-destructive text-background hover:bg-destructive/90" : undefined}
disabled={pending}
aria-busy={pending || undefined}
onClick={confirm}
>
{pending && (
<Loader2Icon
data-icon="inline-start"
className="animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
)}
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
For AI agents
Hand your agent this page, the markdown at /components/confirm-dialog/prompt.md, or the Copy AI prompt button above.
When to use
- Before an action that cannot be undone: delete, publish, send, pay
- When a click would affect other people or cost money
- Any confirm that calls a server and needs a pending state
When not to use
- Routine actions that are easy to reverse: offer undo in a toast instead
- Collecting input: use a Dialog with a form, not an alert dialog
Agent notes
- onConfirm is a function, so the component that renders ConfirmDialog must be a client component ("use client")
- Built on shadcn's AlertDialog, so it traps focus and ignores clicks outside by design
- Pass a Button as children and it becomes the trigger; omit children and control it with open
- While onConfirm is pending, Escape, cancel and confirm are all disabled
- If onConfirm throws, the dialog stays open and pending clears; handle the error where you call it