Patterns
Confirm Dialog
A dialog that asks the user to confirm an action before the action runs.
Patterns
A dialog that asks the user to confirm an action before the action runs.
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')
}}
/>
)
}destructive Button opens the dialog. The confirm button of the dialog is destructive-solid.confirmLabel names the action, not "OK".Import the component. It has one part and builds the dialog from its props. Pass the button that opens the dialog to trigger.
import { Button, ConfirmDialog } from 'ferry-ui'
<ConfirmDialog
trigger={<Button variant="destructive" />}
title=""
description=""
confirmLabel=""
onConfirm={() => {}}
/>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.
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')
}}
/>
)
}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.
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.')
}}
/>
)
}The tone prop sets the variant of the confirm button. Use cancelLabel to change the text of the Cancel button.
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. |
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.
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')
}}
/>
)
}To open the dialog from an item of a Dropdown Menu, control the state. Pass open and onOpenChange. Do not pass trigger.
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')
}}
/>
</>
)
}The children show below the description. Use them for a Callout, a Checkbox or a list of items.
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>
)
}confirmText, it goes to the field.alert role. Screen readers read it when it shows.ConfirmDialog accepts only the props of this table.
| Prop | Type | Default |
|---|---|---|
titleRequired | ReactNode | - |
Title, phrased as a question naming the target ("Delete project “Marketing site”?"). | ||
onConfirmRequired | () => 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 | ||
open | boolean | - |
Controlled open state. Pair it with | ||
defaultOpen | boolean | false |
Initial open state when uncontrolled. | ||
onOpenChange | ((open: boolean) => void) | - |
Called with the next open state: trigger click, Cancel, Escape, and | ||
trigger | ReactNode | - |
Element that opens the dialog on click (rendered through | ||
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 | ||
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 | ||
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 | ||
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 ( | ||