# Confirm Dialog

A dialog that asks the user to confirm an action before the action runs.

```tsx
import { Button, ConfirmDialog, toast } from 'ferry-ui'
import { Trash2 } from 'lucide-react'

export default function ConfirmDialogHero() {
  return (
    <ConfirmDialog
      trigger={
        <Button variant="destructive" icon={<Trash2 />}>
          Delete project
        </Button>
      }
      title="Delete project “Billing portal”?"
      description="The project, its invoices and its API keys are deleted. You cannot undo this."
      confirmLabel="Delete project"
      onConfirm={() => {
        toast.success('Project deleted')
      }}
    />
  )
}
```

## Usage guidelines

- **Confirm each destructive action.** A `destructive` [Button](/docs/components/button) opens the dialog. The confirm button of the dialog is `destructive-solid`.
- **Name the target and the action.** The title is a question that names the target. `confirmLabel` names the action, not "OK".
- **A form is not a confirmation.** For a form or a flow with steps, use [Dialog](/docs/components/dialog).
- **Do not ask for a small action.** If the user can undo the action, do it. Then offer Undo in a [Toast](/docs/components/toast).
- **Another layout.** For a custom layout, put the parts of [Alert Dialog](/docs/components/alert-dialog) together.

## Anatomy

Import the component. It has one part and builds the dialog from its props. Pass the button that opens the dialog to `trigger`.

```tsx title="Anatomy"

<ConfirmDialog
  trigger={<Button variant="destructive" />}
  title=""
  description=""
  confirmLabel=""
  onConfirm={() => {}}
/>
```

## Examples

### Pending state

Return a promise from `onConfirm`. While the promise is pending, the confirm button shows a spinner. The user cannot close the dialog.

The dialog closes when the promise resolves. Do not close it from `onConfirm`.

```tsx
import { Button, ConfirmDialog, toast } from 'ferry-ui'
import { KeyRound } from 'lucide-react'

// Stands for a request to your server.
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms))

export default function ConfirmDialogPending() {
  return (
    <ConfirmDialog
      trigger={
        <Button variant="destructive" icon={<KeyRound />}>
          Revoke key
        </Button>
      }
      title="Revoke API key “Analytics export”?"
      description="Requests that use this key start to fail immediately."
      confirmLabel="Revoke key"
      onConfirm={async () => {
        await wait(1500)
        toast.success('API key revoked')
      }}
    />
  )
}
```

### Error

If `onConfirm` throws or the promise rejects, the dialog stays open. It shows the message of the error above the buttons. The user can try again.

The error and the typed text reset each time the dialog opens.

```tsx
import { Button, ConfirmDialog } from 'ferry-ui'

// Stands for a request that the server refuses.
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms))

export default function ConfirmDialogError() {
  return (
    <ConfirmDialog
      trigger={<Button variant="destructive">Delete team</Button>}
      title="Delete team “Design”?"
      description="The members of the team keep their accounts."
      confirmLabel="Delete team"
      onConfirm={async () => {
        await wait(800)
        throw new Error('This team owns 2 projects. Move them to another team first.')
      }}
    />
  )
}
```

### Tones

The `tone` prop sets the variant of the confirm button. Use `cancelLabel` to change the text of the Cancel button.

```tsx
import { Button, ConfirmDialog, toast } from 'ferry-ui'

export default function ConfirmDialogTones() {
  return (
    <>
      <ConfirmDialog
        tone="destructive"
        trigger={<Button variant="destructive">Remove member</Button>}
        title="Remove Sam Lee from the team?"
        description="Sam loses access to all the projects of the team."
        confirmLabel="Remove member"
        onConfirm={() => {
          toast.success('Member removed')
        }}
      />
      <ConfirmDialog
        tone="warning"
        trigger={<Button>Pause subscription</Button>}
        title="Pause your subscription?"
        description="Your team keeps read-only access to each project until you resume."
        confirmLabel="Pause subscription"
        cancelLabel="Keep subscription"
        onConfirm={() => {
          toast.success('Subscription paused')
        }}
      />
      <ConfirmDialog
        tone="primary"
        trigger={<Button>Send invoice</Button>}
        title="Send invoice INV-2041?"
        description="The customer gets the invoice by email. After that, you cannot edit it."
        confirmLabel="Send invoice"
        cancelLabel="Not yet"
        onConfirm={() => {
          toast.success('Invoice sent')
        }}
      />
    </>
  )
}
```

| Tone | Confirm button | Use |
| --- | --- | --- |
| `destructive` (default) | `destructive-solid` | Delete, revoke, remove. |
| `warning` | `warning` | An action that disrupts but that the user can undo: pause, suspend. |
| `primary` | `primary` | A safe action with consequences: send, publish. |

### Typed confirmation

With `confirmText`, the user must type this text. The confirm button stays disabled until the text is the same. Use it only for an action with a large effect that the user cannot undo.

```tsx
import { Button, ConfirmDialog, toast } from 'ferry-ui'

export default function ConfirmDialogTyped() {
  return (
    <ConfirmDialog
      trigger={<Button variant="destructive">Delete workspace</Button>}
      title="Delete workspace “acme-marketing”?"
      description="All the projects, members and invoices of this workspace are deleted. You cannot undo this."
      confirmText="acme-marketing"
      confirmLabel="Delete workspace"
      onConfirm={() => {
        toast.success('Workspace deleted')
      }}
    />
  )
}
```

### Open from a menu

To open the dialog from an item of a [Dropdown Menu](/docs/components/dropdown-menu), control the state. Pass `open` and `onOpenChange`. Do not pass `trigger`.

```tsx
import * as React from 'react'
import {
  Button,
  ConfirmDialog,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
  toast,
} from 'ferry-ui'
import { MoreHorizontal, Trash2 } from 'lucide-react'

export default function ConfirmDialogControlled() {
  const [confirmOpen, setConfirmOpen] = React.useState(false)

  return (
    <>
      <div className="flex w-full max-w-sm items-center justify-between gap-3 rounded-lg border bg-surface-100 px-4 py-2.5">
        <span className="text-sm text-foreground">Analytics export</span>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="ghost"
              size="icon-tiny"
              icon={<MoreHorizontal />}
              aria-label="Actions for Analytics export"
            />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem variant="destructive" onSelect={() => setConfirmOpen(true)}>
              <Trash2 /> Revoke key
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
      <ConfirmDialog
        open={confirmOpen}
        onOpenChange={setConfirmOpen}
        title="Revoke API key “Analytics export”?"
        description="Requests that use this key start to fail immediately. You cannot undo this."
        confirmLabel="Revoke key"
        onConfirm={() => {
          toast.success('API key revoked')
        }}
      />
    </>
  )
}
```

### More content

The children show below the description. Use them for a [Callout](/docs/components/callout), a [Checkbox](/docs/components/checkbox) or a list of items.

```tsx
import { Button, Callout, Checkbox, ConfirmDialog, Label, toast } from 'ferry-ui'

export default function ConfirmDialogChildren() {
  return (
    <ConfirmDialog
      trigger={<Button variant="destructive">Remove member</Button>}
      title="Remove Sam Lee from the team?"
      description="Sam loses access to all the projects of the team. You can invite Sam again later."
      confirmLabel="Remove member"
      onConfirm={() => {
        toast.success('Member removed')
      }}
    >
      <Callout tone="warning" size="sm" title="Sam owns 2 projects">
        <ul className="list-disc pl-4">
          <li>Billing portal</li>
          <li>Mobile app</li>
        </ul>
      </Callout>
      <Label className="text-[13px] font-normal">
        <Checkbox defaultChecked />
        Move these projects to my account
      </Label>
    </ConfirmDialog>
  )
}
```

## Accessibility

- When the dialog opens, the focus goes to the Cancel button. With `confirmText`, it goes to the field.
- <Kbd>Esc</Kbd> closes the dialog. A click outside the panel does not close it.
- The error message has the `alert` role. Screen readers read it when it shows.

## API reference

`ConfirmDialog` accepts only the props of this table.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` (required) | `ReactNode` |  | Title, phrased as a question naming the target ("Delete project “Marketing site”?"). |
| `onConfirm` (required) | `() => unknown` |  | Runs the action. If it returns a promise (any thenable), the dialog shows a spinner on the confirm button, cannot be dismissed while pending, closes when the promise resolves and shows the rejection message inline (through `getErrorMessage`) when it rejects, so the user can retry. A synchronous throw is shown the same way; any other return value is ignored and the dialog closes at once. On success the dialog closes by itself: do not close it from `onConfirm`. Runs at most once per click, even on a double-click. |
| `open` | `boolean` |  | Controlled open state. Pair it with `onOpenChange`; omit it (and use `defaultOpen` / `trigger`) for an uncontrolled dialog. |
| `defaultOpen` | `boolean` | `false` | Initial open state when uncontrolled. |
| `onOpenChange` | `((open: boolean) => void)` |  | Called with the next open state: trigger click, Cancel, Escape, and `false` once `onConfirm` succeeds. Never called while a confirmation is pending (the dialog cannot be dismissed mid-request). |
| `trigger` | `ReactNode` |  | Element that opens the dialog on click (rendered through `AlertDialogTrigger asChild`), usually a `Button`. Omit it when the dialog is opened from elsewhere (a menu item, a row action) with `open`. |
| `description` | `ReactNode` |  | Consequences of the action ("This cannot be undone."). Announced as the dialog description. Rendered in a flex column with an 8px gap, so several `<p>` stack nicely. |
| `confirmLabel` | `ReactNode` | `Confirm` | Label of the confirm button. Name the action ("Delete project"), not "OK". Defaults to "Confirm". |
| `cancelLabel` | `ReactNode` | `Cancel` | Label of the safe button. Defaults to "Cancel"; use e.g. "Keep editing" or "Keep subscription" when the action itself is a cancellation, so the two buttons never both read "Cancel". |
| `tone` | `"primary" \| "destructive" \| "warning"` | `destructive` | Colour of the confirm button. Defaults to `destructive`. See `ConfirmDialogTone`. |
| `confirmText` | `string` |  | Typed confirmation: the confirm button stays disabled until the user types this exact text (usually the name of the thing being destroyed). Reserve it for irreversible, high-impact actions — it is friction by design. |
| `confirmTextLabel` | `ReactNode` |  | Label above the typed-confirmation field. Defaults to `Type <confirmText> to confirm.` Override it to translate the sentence (include the text to type yourself). |
| `children` | `ReactNode` |  | Extra content between the description and the typed-confirmation field (a checkbox, a warning callout, a list of affected items). |
| `className` | `string` |  | Classes merged onto the dialog panel (`AlertDialogContent`). |
