# Confirm Dialog

A promise-based confirmation dialog driven by an imperative confirm() call.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/confirm-dialog.json
```

[Registry JSON](https://mwui.vercel.app/r/confirm-dialog.json)

## Preview

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";

import { ConfirmDialogProvider, useConfirm } from "@/components/ui/confirm-dialog";

function Demo() {
  const confirm = useConfirm();
  const [log, setLog] = React.useState("Nothing yet.");

  const discard = async () => {
    const ok = await confirm({
      title: "Discard changes?",
      description: "Your edits to this draft will be lost.",
      confirmLabel: "Discard",
    });

    setLog(ok ? "Discarded." : "Kept the draft.");
  };

  const revoke = async () => {
    const ok = await confirm({
      title: "Revoke API key?",
      description: "Requests using this key will start failing immediately.",
      confirmLabel: "Revoke",
      destructive: true,
      onConfirm: () =>
        new Promise<void>((resolve) => {
          setTimeout(resolve, 1200);
        }),
    });

    setLog(ok ? "Revoked after a pending round-trip." : "Cancelled.");
  };

  const deleteOrganization = async () => {
    const ok = await confirm({
      title: "Delete organization",
      description: "This cannot be undone.",
      confirmText: "acme-inc",
      confirmLabel: "Delete organization",
      destructive: true,
    });

    setLog(ok ? "Organization deleted." : "Cancelled.");
  };

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <div className="flex flex-wrap gap-2">
        <Button size="sm" variant="outline" onClick={() => void discard()}>
          Discard
        </Button>
        <Button size="sm" variant="outline" onClick={() => void revoke()}>
          Revoke with pending
        </Button>
        <Button size="sm" variant="outline" onClick={() => void deleteOrganization()}>
          Type to confirm
        </Button>
      </div>

      <p className="text-xs text-muted-foreground">{log}</p>
    </div>
  );
}

export function Preview() {
  return (
    <ConfirmDialogProvider>
      <Demo />
    </ConfirmDialogProvider>
  );
}
```


## Source

### ui/confirm-dialog.tsx

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";

type ConfirmOptions = {
  title: React.ReactNode;
  description?: React.ReactNode;
  confirmLabel?: string;
  cancelLabel?: string;
  destructive?: boolean;
  confirmText?: string;
  onConfirm?: () => void | Promise<void>;
};

type ConfirmFunction = (options: ConfirmOptions) => Promise<boolean>;

const ConfirmDialogContext = React.createContext<ConfirmFunction | null>(null);

type ConfirmDialogProviderProps = {
  children: React.ReactNode;
  confirmLabel?: string;
  cancelLabel?: string;
};

function ConfirmDialogProvider({
  children,
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
}: ConfirmDialogProviderProps) {
  const [request, setRequest] = React.useState<ConfirmOptions | null>(null);
  const [open, setOpen] = React.useState(false);
  const [pending, setPending] = React.useState(false);
  const [error, setError] = React.useState<string | null>(null);
  const [typed, setTyped] = React.useState("");

  const resolveRef = React.useRef<((result: boolean) => void) | null>(null);
  const confirmTextId = React.useId();

  const settle = React.useCallback((result: boolean) => {
    resolveRef.current?.(result);
    resolveRef.current = null;
    setOpen(false);
  }, []);

  const confirm = React.useCallback<ConfirmFunction>((options) => {
    resolveRef.current?.(false);

    setRequest(options);
    setTyped("");
    setError(null);
    setPending(false);
    setOpen(true);

    return new Promise<boolean>((resolve) => {
      resolveRef.current = resolve;
    });
  }, []);

  const handleConfirm = async () => {
    if (!request) return;

    if (!request.onConfirm) {
      settle(true);
      return;
    }

    setError(null);
    setPending(true);

    try {
      await request.onConfirm();
      settle(true);
    } catch (thrown) {
      setError(thrown instanceof Error ? thrown.message : "Something went wrong.");
    } finally {
      setPending(false);
    }
  };

  const confirmDisabled =
    pending || (request?.confirmText !== undefined && typed !== request.confirmText);

  return (
    <ConfirmDialogContext.Provider value={confirm}>
      {children}

      <Dialog
        open={open}
        onOpenChange={(next) => {
          if (!next && !pending) settle(false);
        }}
        onOpenChangeComplete={(next) => {
          if (!next) setRequest(null);
        }}
      >
        <DialogContent showCloseButton={false}>
          <DialogHeader>
            <DialogTitle>{request?.title}</DialogTitle>
            {request?.description ? (
              <DialogDescription>{request.description}</DialogDescription>
            ) : null}
          </DialogHeader>

          {request?.confirmText ? (
            <div className="flex flex-col gap-2">
              <Label htmlFor={confirmTextId}>
                Type <span className="font-mono text-foreground">{request.confirmText}</span> to
                confirm
              </Label>
              <Input
                id={confirmTextId}
                value={typed}
                autoComplete="off"
                disabled={pending}
                onChange={(event) => setTyped(event.target.value)}
              />
            </div>
          ) : null}

          {error ? (
            <p role="alert" className="text-xs text-destructive">
              {error}
            </p>
          ) : null}

          <DialogFooter>
            <Button variant="outline" disabled={pending} onClick={() => settle(false)}>
              {request?.cancelLabel ?? cancelLabel}
            </Button>
            <Button
              variant={request?.destructive ? "destructive" : "default"}
              disabled={confirmDisabled}
              onClick={() => {
                void handleConfirm();
              }}
            >
              {pending ? <Spinner data-icon="inline-start" /> : null}
              {request?.confirmLabel ?? confirmLabel}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </ConfirmDialogContext.Provider>
  );
}

function useConfirm(): ConfirmFunction {
  const confirm = React.useContext(ConfirmDialogContext);

  if (!confirm) {
    throw new Error("useConfirm must be used inside a <ConfirmDialogProvider>.");
  }

  return confirm;
}

export {
  ConfirmDialogProvider,
  useConfirm,
  type ConfirmFunction,
  type ConfirmOptions,
  type ConfirmDialogProviderProps,
};
```



## Usage

`window.confirm` has the right API and the wrong everything else. This is the same call — one line,
inline with the code it guards — rendered as a real dialog.

```tsx
import { useConfirm } from "@/components/ui/confirm-dialog";

function DeleteButton({ project }: { project: Project }) {
  const confirm = useConfirm();

  async function handleDelete() {
    const ok = await confirm({
      title: "Delete project?",
      description: `${project.name} and all of its data will be removed.`,
      confirmLabel: "Delete",
      destructive: true,
    });

    if (!ok) return;
    await deleteProject(project.id);
  }

  return <Button variant="destructive" onClick={handleDelete}>Delete</Button>;
}
```

No `open` state, no pending-action state, no dialog markup at the call site — the branch reads the
way the decision actually works.

## Setup

Mount the provider once, near the root:

```tsx
import { ConfirmDialogProvider } from "@/components/ui/confirm-dialog";

export function App({ children }: { children: React.ReactNode }) {
  return <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
}
```

One dialog instance serves the whole tree. Cancelling — button, `Escape`, or the backdrop — resolves
`false`; nothing rejects, so there is no `try`/`catch` around a user saying no.

## Running the action inside the dialog

Pass `onConfirm` and the dialog stays open while it settles: the confirm button shows a spinner and
both buttons lock. If it throws, the message renders in the dialog and the user can retry — closing
on failure would leave them guessing whether the delete happened.

```tsx
await confirm({
  title: "Revoke API key?",
  destructive: true,
  onConfirm: () => revokeKey(key.id),
});
```

Without `onConfirm` the dialog closes immediately and you run the work yourself.

## Type-to-confirm

For actions that are genuinely unrecoverable, require the name:

```tsx
await confirm({
  title: "Delete organization",
  description: "This cannot be undone.",
  confirmText: organization.slug,
  confirmLabel: "Delete organization",
  destructive: true,
});
```

The confirm button stays disabled until the text matches exactly.

## Options

| Option         | Description                                                             |
| -------------- | ----------------------------------------------------------------------- |
| `title`        | Required. The question being asked.                                      |
| `description`  | Supporting detail. Accepts nodes, not just strings.                      |
| `confirmLabel` / `cancelLabel` | Override the provider defaults per call.                |
| `destructive`  | Style the confirm action as destructive.                                 |
| `confirmText`  | Require this exact string to be typed first.                             |
| `onConfirm`    | Run the action inside the dialog, with pending and error states.         |

## Behavior

- Calling `confirm` while a dialog is open resolves the previous one as cancelled, so a stray second
  call cannot leave a promise hanging forever.
- While `onConfirm` is pending, `Escape` and the backdrop do not close the dialog.
- The request stays mounted through the close animation and is cleared afterwards, so the text does
  not vanish mid-fade.
- Writing the label as the verb — "Delete", not "OK" — is what makes the dialog readable when it is
  the only thing on screen.

