Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
childrenReactNodeThe trigger element, rendered with asChild.
titlestringThe question, as a short sentence.
descriptionstringWhat happens if they confirm.
confirmLabelstring"Confirm"Label of the confirm button.
cancelLabelstring"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.
openbooleanControlled open state.
onOpenChange(open: boolean) => voidFires 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