Confirm dialog
The standard confirmation for destructive actions, with an optional typed-name check.
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import { Trash2Icon } from "lucide-react";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); return ( <> <Button type="button" variant="destructive" onClick={() => setOpen(true)} > <Trash2Icon data-icon="inline-start" aria-hidden="true" /> Delete supplier </Button> <ConfirmDialog open={open} onOpenChange={setOpen} title="Delete Northwind Freight?" description="Its 38 paid invoices stay in your payment history. The 2 open invoices are removed from Friday's run." confirmLabel="Delete supplier" onConfirm={() => toast.add({ title: "Northwind Freight deleted" }) } /> </> );}Usage#
Confirm dialog is the one confirmation the suite uses before a consequential action: delete a supplier, remove an API key, rotate a secret, turn off the last live key. You pass the question, the consequence and a confirm label, and it handles the rest: Cancel takes first focus, destructive actions get the red tint, a returned promise shows a spinner and holds the dialog open, and confirmText adds a typed-name check for losses that can't be undone. The common mistake is a vague confirm label. The button repeats the consequence (Delete supplier, Remove key), never OK or Yes.
When to use
- Before a delete or remove that can't be undone: Delete Northwind Freight?, Remove key?
- Before an action with a real consequence that isn't a delete, such as rotating a key or turning off autopay, with
destructive={false}. - With
confirmTextwhen the loss is large and permanent, such as deleting a payment run with its invoices, so the name has to be typed. - When confirming calls the server: return the promise from
onConfirmand the dialog waits with a spinner. - From a table or list row, with
usePendingItemholding the row while the dialog animates closed.
When not to use
- For an action that can be undone. Do it, then offer Undo in a toast. Use Toast
- For a decision with three answers, or a layout the props can't express. Use Alert dialog
- For a single high-stakes button in place, where a dialog would break the flow. Use Hold button
- For a form with fields and a Save. Use Form dialog
Destructive is a tint
The One Filled Button Rule
Anatomy#
Remove Halcyon?
Scheduled payments to Halcyon are cancelled.
Halcyon to confirm- Title. The question, naming the object: Delete Northwind Freight? Rendered by
AlertDialogTitle. - Description. What happens and to what, in one or two sentences of Slate Meta.
- Body. Optional
childrenbetween the header and the footer, such as a grace-period select when rotating a key. - Typed check. With
confirmText: a 13px Slate Meta label with the name in inline code, and an input with autocomplete, capitalization and spellcheck off. - Cancel. An outline
AlertDialogCancel, first in the DOM and first to take focus. Labelled bycancelLabel. - Confirm. The red tint by default, the filled indigo button with
destructive={false}. Shows a 14px spinner before its label while pending.
Examples#
Not destructive, with a body
destructive={false} makes the confirm the filled indigo button. children render between the header and the footer, here the grace period for the old key.
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Field, FieldLabel } from "@oration/canon/components/field";import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import { RefreshCwIcon } from "lucide-react";import * as React from "react";export function NotDestructive() { const [open, setOpen] = React.useState(false); const [grace, setGrace] = React.useState("24 hours"); const id = React.useId(); return ( <> <Button type="button" variant="outline" onClick={() => setOpen(true)} > <RefreshCwIcon data-icon="inline-start" aria-hidden="true" /> Rotate key </Button> <ConfirmDialog open={open} onOpenChange={setOpen} title="Rotate the production API key?" description="You get a new key right away. Update the ERP sync before the old key stops working." confirmLabel="Rotate key" destructive={false} onConfirm={() => toast.add({ type: "success", title: "Key rotated", description: grace === "Now" ? "The old key stopped working." : `The old key works for ${grace}.`, }) } > <Field> <FieldLabel htmlFor={id}> Keep the old key working for </FieldLabel> <OptionSelect id={id} label="Keep the old key working for" value={grace} onValueChange={setGrace} options={["Now", "24 hours", "7 days"]} /> </Field> </ConfirmDialog> </> );}Typed confirmation
confirmText adds a field that must match exactly before the confirm enables. Focus starts in the field, Enter confirms and the surface widens to 28rem. Type PR-0412.
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import { Trash2Icon } from "lucide-react";import * as React from "react";export function TypedConfirmation() { const [open, setOpen] = React.useState(false); return ( <> <Button type="button" variant="destructive" onClick={() => setOpen(true)} > <Trash2Icon data-icon="inline-start" aria-hidden="true" /> Delete payment run </Button> <ConfirmDialog open={open} onOpenChange={setOpen} title="Delete payment run PR-0412?" description="Its 212 invoices go back to Approved and the bank file for Friday, Oct 2 is withdrawn. This can't be undone." confirmLabel="Delete run" confirmText="PR-0412" onConfirm={() => toast.add({ title: "PR-0412 deleted", description: "212 invoices are back in Approved.", }) } /> </> );}Waiting on the server
Return a promise from onConfirm. The confirm shows a spinner, both buttons disable and Esc is ignored until it resolves, then the dialog closes.
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Pending() { const [open, setOpen] = React.useState(false); return ( <> <Button type="button" variant="outline" onClick={() => setOpen(true)} > Turn off autopay </Button> <ConfirmDialog open={open} onOpenChange={setOpen} title="Turn off autopay for Orchard Street?" description="New invoices from Orchard Street wait for approval. The 3 already scheduled still go out Friday." confirmLabel="Turn off autopay" onConfirm={() => new Promise<void>((resolve) => { window.setTimeout(() => { toast.add({ title: "Autopay turned off for Orchard Street", }); resolve(); }, 1400); }) } /> </> );}From a row action with usePendingItem
show(row) opens the dialog for that row. item outlives the close, so the title still names the account while the dialog animates out after the row is gone.
- Northwind FreightChase ending 4417
- HalcyonWells Fargo ending 0932
- Orchard StreetSVB ending 7781
import { Button } from "@oration/canon/components/button";import { ConfirmDialog, usePendingItem } from "@oration/canon/components/confirm-dialog";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon, Trash2Icon } from "lucide-react";import * as React from "react";export function FromRows() { const [accounts, setAccounts] = React.useState([ { id: "a1", supplier: "Northwind Freight", bank: "Chase", last4: "4417", }, { id: "a2", supplier: "Halcyon", bank: "Wells Fargo", last4: "0932" }, { id: "a3", supplier: "Orchard Street", bank: "SVB", last4: "7781" }, ]); const remove = usePendingItem<(typeof accounts)[number]>(); return ( <div className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <ul className="divide-y divide-border"> {accounts.map((account) => ( <li key={account.id} className="flex items-center gap-3 py-2 pr-2 pl-4 text-13" > <span className="min-w-0 flex-1"> <span className="block font-medium"> {account.supplier} </span> <span className="block text-muted-foreground tabular-nums"> {account.bank} ending {account.last4} </span> </span> <DropdownMenu> <Tooltip> <TooltipTrigger render={ <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Actions for ${account.supplier}`} /> } /> } > <MoreHorizontalIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Actions</TooltipContent> </Tooltip> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuItem onClick={() => toast.add({ title: `Verification sent to ${account.supplier}`, }) } > Re-verify account </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive" onClick={() => remove.show(account)} > <Trash2Icon aria-hidden="true" /> Remove account </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </li> ))} {accounts.length === 0 ? ( <li className="px-4 py-6 text-center text-13 text-muted-foreground"> No bank accounts on file. </li> ) : null} </ul> <ConfirmDialog open={remove.open} onOpenChange={remove.setOpen} title={`Remove ${remove.item?.supplier ?? "this supplier"}'s bank account?`} description={`Payments to ${remove.item?.supplier ?? "this supplier"} pause until a new account is verified.`} confirmLabel="Remove account" onConfirm={() => { const target = remove.item; if (!target) return; setAccounts((all) => all.filter((a) => a.id !== target.id)); toast.add({ title: `Removed ${target.bank} ending ${target.last4}`, }); }} /> </div> );}States#
import { Button } from "@oration/canon/components/button";import { Spinner } from "@oration/canon/components/spinner";export function StatesRow() { const states = [ { label: "Waiting for the name", confirm: ( <Button type="button" variant="destructive" size="sm" disabled> Delete run </Button> ), }, { label: "Ready", confirm: ( <Button type="button" variant="destructive" size="sm"> Delete run </Button> ), }, { label: "Pending", confirm: ( <Button type="button" variant="destructive" size="sm" disabled> <span aria-hidden="true" className="flex"> <Spinner className="size-3.5" /> </span> Delete run </Button> ), }, ]; return ( <div className="grid w-full gap-3 sm:grid-cols-3" inert> {states.map((state) => ( <div key={state.label} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> {state.label} </span> <div className="flex justify-end gap-2 rounded-[10px] bg-muted/70 p-3"> <Button type="button" variant="outline" size="sm" disabled={state.label === "Pending"} > Cancel </Button> {state.confirm} </div> </div> ))} </div> );}| State | Treatment |
|---|---|
| Closed | Nothing is rendered. With usePendingItem, the last item is kept so the title doesn't change while the dialog animates out. |
| Open | Focus is on Cancel, or on the typed input when confirmText is set. The typed value and pending state reset every time it opens. |
| Waiting for the name | With confirmText, the confirm button is disabled until the trimmed input matches exactly, including case. |
| Disabled | confirmDisabled disables the confirm button for any other reason, such as a body field that isn't filled in. |
| Pending | When onConfirm returns a promise: a spinner before the label, both buttons disabled, and Esc ignored until it settles. |
| Closing | Closes when onConfirm returns, or when its promise resolves. A rejected promise leaves it open. |
Behavior#
- Controlled only:
openandonOpenChangeare required. The trigger lives outside, usually a menu item, a row action or a destructive button in a settings section. - Built on Alert dialog, so the scrim doesn't close it. Esc and Cancel do, except while pending.
- Confirming calls
onConfirm. A plain return closes the dialog right away. A returned promise keeps it open with the spinner, then closes on resolve. On reject it stays open and the error propagates, so catch it and show a toast yourself. - With
confirmText, the input sits in a form, so Enter confirms once the name matches. The comparison trims whitespace and is case-sensitive. The surface widens to 28rem. usePendingItem<T>()returns{ item, open, show, setOpen, close }. Callshow(row)from the trigger, readitemin the title, and passopenandsetOpento the dialog.itemsurvives the close so the title never flashes to a fallback.- Timings are Alert dialog's: 200ms in from 0.96, 140ms out toward 0.98.
Choosing an overlay#
Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog | A focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer. | Centered, 24rem by default, up to 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog (this page) | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog | A decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it. | Centered, 20 or 24rem | Esc or an answer. The scrim doesn't close it |
| Form dialog | Creating or editing one record with two to six fields, a submit and a cancel. | Centered, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
Delete Northwind Freight?
The 2 open invoices are removed from Friday's run.
Are you sure?
This action cannot be undone.
Delete payment run PR-0412?
212 invoices go back to Approved and the bank file is withdrawn.
PR-0412 to confirmSkip INV-20944?
It moves to next Friday's run.
INV-20944 to confirmContent#
- Title: a question with the object's name and a question mark. Remove Halcyon's bank account?, not Are you sure?
- Description: the consequence in one or two sentences. Name what stops working and what's kept. Offer the gentler alternative if there is one: To pause it instead, turn it off.
- Confirm label: the verb and object from the title. Delete supplier, Remove key, Rotate key.
- Cancel label: keep Cancel unless backing out has a clearer name, such as Keep key.
- The typed label defaults to Type {name} to confirm. Replace it with
confirmTextLabelonly to say where the name comes from. - After it succeeds, confirm with a toast that names the object: Northwind Freight deleted.
Accessibility#
- The surface is
role="alertdialog", named by the title and described by the description, so the question and its consequence are announced together. - Initial focus lands on Cancel, so a stray Enter backs out. With
confirmText, it lands on the input instead. - The typed input is labelled by its visible label through
htmlFor. - While pending, the confirm label stays in place and the spinner is hidden from assistive tech. Nothing announces the wait; follow up with a toast when it settles.
- Focus returns to the element that opened it. When the trigger was a row that's now deleted, move focus somewhere sensible, such as the table.
| Keys | Action |
|---|---|
| Tab | Moves between the input, Cancel and the confirm button, wrapping. |
| Enter | Activates the focused button. In the typed input, confirms once the name matches. |
| Esc | Cancels, unless a confirmation is pending. |
Design tokens#
| Token | Used for |
|---|---|
--destructive | Confirm text and its 10% tint when destructive |
--primary | Confirm fill when destructive={false} |
--muted | Footer band at 50% |
--muted-foreground | Description and the typed-check label |
--popover | Surface fill |
shadow-lg | The dialog shadow |
API reference#
ConfirmDialog
A controlled confirmation built on Alert dialog. Takes no other props.
| Prop | Type | Default | Description |
|---|---|---|---|
openRequired | boolean | No default | Whether it is open. |
onOpenChangeRequired | (open: boolean) => void | No default | Called on Cancel, Esc and after a confirm. Ignored while pending. |
titleRequired | React.ReactNode | No default | The question, naming the object. |
descriptionRequired | React.ReactNode | No default | What happens and to what. |
confirmLabelRequired | React.ReactNode | No default | The confirm button's label. Repeat the consequence. |
cancelLabel | React.ReactNode | "Cancel" | The cancel button's label. |
onConfirmRequired | () => unknown | No default | Runs on confirm. Return a promise to keep the dialog open with a spinner until it settles. |
destructive | boolean | true | The red tint. Set false for a consequential action that doesn't destroy anything. |
confirmDisabled | boolean | No default | Disables the confirm button. |
confirmText | string | No default | The exact text to type before confirming. Widens the surface to 28rem. |
confirmTextLabel | React.ReactNode | No default | Replaces the Type {confirmText} to confirm label. |
children | React.ReactNode | No default | Body content between the header and the footer. |
className | string | No default | Merged onto AlertDialogContent. |
usePendingItem
usePendingItem<T>() holds the target of a confirmation. Returns { item: T | null, open: boolean, show: (item: T) => void, setOpen: (open: boolean) => void, close: () => void }.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
A rejected onConfirm promise isn't caught. The dialog stays open with no message and the rejection surfaces as an unhandled error, so every async caller has to catch and toast.
Pending isn't announced: there is no aria-busy and the spinner is hidden. Screen reader users hear nothing until the dialog closes.
The typed check is case-sensitive with no hint. Typing halcyon for Halcyon keeps the button disabled without saying why.
Some callers, such as confirm-delete-dialog.tsx, call onOpenChange(false) inside onConfirm, which the component already does.