Destructive actions
Undo first, confirm when undo is impossible, and type to confirm when the loss is large.
The problem#
Some actions lose things: invoices, payments, whole suppliers. Protect people from the irreversible ones without taxing the reversible ones.
The usual answer is a confirmation dialog on everything that removes something. It fails twice. People learn to click through Are you sure? without reading, so the dialog stops protecting the one action that needed it. And every reversible archive or unassign now costs an extra click and a context switch.
In accounts payable the stakes are uneven. Archiving a draft invoice costs nothing to reverse. Voiding a payment that already left for the bank, or deleting a supplier with three years of remittance history, can't be taken back.
The solution in Canon#
Match the friction to the loss. Make it reversible and offer Undo when you can; confirm when you can't; ask for the name when the loss is large.
Undo beats confirm
Name the thing, repeat the verb
Destructive is a red tint
Decision guide#
Pick the lightest protection that matches the loss.
| The loss | Reversible | Pattern | Example |
|---|---|---|---|
| Archive, remove from a view, unassign, move to trash | Yes | Do it, toast with Undo | Archive INV-20451 |
| The same, in bulk | Yes | Do it, toast with the count and Undo | Archive 12 drafts |
| Irreversible, one record, contained | No | Confirm dialog | Void payment PAY-1182 |
| Irreversible and large: history, many records, other people | No | Type the name to confirm | Delete supplier Halcyon Packaging |
| Irreversible, in a live panel a dialog would cover | No | Hold button | Cancel RUN-0932 while it sends |
| Workspace or account | No | Type to confirm, from a section at the end of settings | Delete the Cedarline workspace |
Prefer undo#
Archive a draft and it goes at once. The toast names it and offers Undo for five seconds; the record is kept for 30 days either way.
Draft invoices
4 drafts- $2,140.00
Northwind Freight
INV-20451
- $612.40
Orchard Street Foods
INV-20453
- $4,912.50
Halcyon Packaging
INV-20455
- $1,264.00
Pinecrest Supply
INV-20456
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArchiveIcon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function UndoFirst() { type Draft = { id: string; supplier: string; amount: string }; const all: Draft[] = [ { id: "INV-20451", supplier: "Northwind Freight", amount: "$2,140.00" }, { id: "INV-20453", supplier: "Orchard Street Foods", amount: "$612.40", }, { id: "INV-20455", supplier: "Halcyon Packaging", amount: "$4,912.50" }, { id: "INV-20456", supplier: "Pinecrest Supply", amount: "$1,264.00" }, ]; const [rows, setRows] = React.useState<Draft[]>(all); const archive = (row: Draft) => { const index = rows.findIndex((item) => item.id === row.id); setRows((list) => list.filter((item) => item.id !== row.id)); toast.add({ title: `${row.id} archived`, description: `${row.supplier}, ${row.amount}. Archived drafts are kept for 30 days.`, actionProps: { children: "Undo", onClick: () => setRows((list) => { if (list.some((item) => item.id === row.id)) return list; const next = [...list]; next.splice(Math.min(index, next.length), 0, row); return next; }), }, }); }; return ( <section className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-3"> <h3 className="text-sm font-semibold">Draft invoices</h3> <span className="text-xs text-muted-foreground tabular-nums"> {rows.length} drafts </span> </div> {rows.length ? ( <ul className="px-2 py-1"> {rows.map((row) => ( <li key={row.id} className="group/row flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-muted/70" > <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {row.supplier} </p> <p className="font-mono text-xs text-muted-foreground"> {row.id} </p> </div> <span className="text-13 font-medium tabular-nums"> {row.amount} </span> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-sm" aria-label={`Archive ${row.id}`} className="text-muted-foreground" onClick={() => archive(row)} /> } > <ArchiveIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Archive</TooltipContent> </Tooltip> </li> ))} </ul> ) : ( <div className="flex flex-col items-center gap-2 px-4 py-8 text-center"> <p className="text-13 font-medium">No drafts</p> <Button variant="outline" size="sm" onClick={() => setRows(all)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reset example </Button> </div> )} </section> );}- Make the action reversible in the data first: archive instead of delete, a trash kept for 30 days, a soft void. Then the interface can be fast.
- The toast is past tense and names the record: INV-20451 archived. Undo puts it back in the same place.
- Toasts leave after five seconds and pause while hovered or focused. If someone needs longer, the record lives in Archived.
Confirm when undo is impossible#
A payment that has left for the bank can't be un-voided. The dialog says exactly what happens and the button repeats it.
PAY-1182
ACH to Northwind Freight
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ConfirmIrreversible() { const [open, setOpen] = React.useState(false); const [voided, setVoided] = React.useState(false); return ( <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <p className="font-mono text-xs text-muted-foreground"> PAY-1182 </p> <p className="text-sm font-semibold"> ACH to Northwind Freight </p> </div> <span className="text-sm font-semibold tabular-nums"> $18,240.00 </span> </div> <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5"> {voided ? ( <StatusLabel tone="danger" className="text-muted-foreground" > Voided </StatusLabel> ) : ( <StatusLabel tone="primary" className="text-muted-foreground" > Sent to bank, settles Sep 30 </StatusLabel> )} {voided ? ( <Button variant="ghost" size="sm" onClick={() => setVoided(false)} > Reset example </Button> ) : ( <Button variant="destructive" size="sm" onClick={() => setOpen(true)} > Void payment </Button> )} </div> <ConfirmDialog open={open} onOpenChange={setOpen} title="Void payment PAY-1182?" description="The $18,240.00 ACH to Northwind Freight is recalled from the bank, and INV-20417 goes back to unpaid. This can't be undone." confirmLabel="Void payment" onConfirm={async () => { await new Promise((resolve) => setTimeout(resolve, 700)); setVoided(true); toast.add({ title: "PAY-1182 voided", description: "INV-20417 is unpaid again and back in the queue.", }); }} /> </div> );}Type to confirm when the loss is large#
Deleting a supplier takes its invoices, remittances and W-9 with it. Typing the name proves the person is looking at the right record. The button stays disabled until it matches.
Halcyon Packaging
Supplier since March 2023
- Invoices
- 14
- Remittances
- 126
- Paid to date
- $412,906.10
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { toast } from "@oration/canon/components/toast";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function TypeToConfirm() { const [open, setOpen] = React.useState(false); const [deleted, setDeleted] = React.useState(false); if (deleted) { return ( <div className="flex w-full max-w-lg flex-col items-center gap-2 rounded-xl bg-card px-4 py-8 text-center shadow-border"> <p className="text-13 font-medium"> Halcyon Packaging was deleted </p> <Button variant="outline" size="sm" onClick={() => setDeleted(false)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reset example </Button> </div> ); } return ( <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex items-center gap-3"> <MonogramTile name="Halcyon Packaging" color="teal" size="lg" /> <div className="min-w-0 flex-1"> <p className="text-sm font-semibold">Halcyon Packaging</p> <p className="text-xs text-muted-foreground"> Supplier since March 2023 </p> </div> </div> <dl className="grid grid-cols-3 gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"> <div> <dt className="text-xs text-muted-foreground">Invoices</dt> <dd className="font-medium tabular-nums">14</dd> </div> <div> <dt className="text-xs text-muted-foreground"> Remittances </dt> <dd className="font-medium tabular-nums">126</dd> </div> <div> <dt className="text-xs text-muted-foreground"> Paid to date </dt> <dd className="font-medium tabular-nums">$412,906.10</dd> </div> </dl> <div className="flex justify-end"> <Button variant="destructive" onClick={() => setOpen(true)}> Delete supplier </Button> </div> <ConfirmDialog open={open} onOpenChange={setOpen} title="Delete supplier Halcyon Packaging?" description="Their 14 invoices, 126 remittances and W-9 are deleted with them, and open invoices drop out of Thursday's run. This can't be undone." confirmLabel="Delete supplier" confirmText="Halcyon Packaging" onConfirm={async () => { await new Promise((resolve) => setTimeout(resolve, 800)); setDeleted(true); toast.add({ title: "Halcyon Packaging deleted" }); }} /> </div> );}The typed check is case-sensitive and gives no hint, so halcyon packaging keeps the button disabled without saying why. Recorded on Confirm dialog.
Hold to confirm#
When the action lives in a panel someone is watching, a dialog would cover the thing they're watching. A two-second hold asks for intent without leaving the panel. Release early and it snaps back.
RUN-0932
Payment run for Thursday, October 1
import { Button } from "@oration/canon/components/button";import { HoldButton } from "@oration/canon/components/hold-button";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function HoldToConfirm() { const [cancelled, setCancelled] = React.useState(false); return ( <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <p className="font-mono text-xs text-muted-foreground"> RUN-0932 </p> <p className="text-sm font-semibold"> Payment run for Thursday, October 1 </p> </div> {cancelled ? ( <StatusLabel tone="danger" className="text-muted-foreground" > Cancelled </StatusLabel> ) : ( <StatusLabel tone="primary" pulse className="text-muted-foreground" > Sending </StatusLabel> )} </div> <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"> <span className="text-muted-foreground tabular-nums"> {cancelled ? "0 of 57 sent" : "12 of 57 sent, $48,102.40 so far"} </span> <span className="font-medium tabular-nums">$241,908.62</span> </div> <div className="flex justify-end"> {cancelled ? ( <Button variant="ghost" size="sm" onClick={() => setCancelled(false)} > Reset example </Button> ) : ( <HoldButton holdingLabel="Keep holding to cancel" onConfirm={() => { setCancelled(true); toast.add({ title: "RUN-0932 cancelled", description: "The 45 unsent payments stay approved for the next run.", }); }} > Hold to cancel run </HoldButton> )} </div> </div> );}Wording#
A confirmation is read once, quickly. Every word has to point at the specific loss.
| Part | Rule | Example |
|---|---|---|
| Title | A question with the verb and the specific record. | Delete supplier Halcyon Packaging? |
| Description | What is lost, who it affects, and whether it can be undone. One or two sentences. | Their 14 invoices, 126 remittances and W-9 are deleted with them. This can't be undone. |
| Confirm | The verb and the noun again, in the red tint. | Delete supplier |
| Cancel | Cancel, or Keep editing when the loss is unsaved work. Focus starts here. | Cancel |
| Toast after | Past tense, names the record, offers Undo when it can. | PAY-1182 voided |
Delete supplier Halcyon Packaging?
Their 14 invoices and 126 remittances are deleted with them. This can't be undone.
Are you sure?
This action cannot be undone.
Placement and color#
Destructive actions sit last and look different, so nobody hits one on the way to something else.
| Where | Treatment |
|---|---|
| Button | variant="destructive": bg-destructive/10 with Signal Red text, /20 on hover (/20 and /30 in dark). Never solid. |
| Menu item | variant="destructive": red text and icon, a 10% red highlight. Last in the menu, after a separator. |
| Hold button | The same red tint at rest. While held, a solid red fill sweeps across it over two seconds; it is the only solid red in the system and exists only while you hold. |
| Dialog footer | Cancel on the left, the destructive confirm on the right. Neither is indigo. |
| Settings | Workspace and account deletion sit in the last section of the page, after everything else. |
Accessibility#
Confirmation has to work the same for keyboard and screen reader users.
- Confirm dialogs are alert dialogs with a title and description. Focus starts on Cancel, or on the name field in the typed variant.
- While
onConfirmis pending, the dialog stays open and both buttons disable. Escape and the backdrop don't close it mid-request. - The Undo in a toast is a real button. Toasts pause while focused, so reaching Undo by keyboard doesn't race the timer.
- Hold buttons work with Enter and Space held down. Their label has to say Hold to, because nothing announces the hold starting.
- Icon-only destructive or archive buttons carry an
aria-labelwith the record (Archive INV-20451) and a tooltip. - Red is never the only signal: the verb in the label says what will happen.
| Keys | Action |
|---|---|
| Esc | Closes a confirm dialog without acting, unless it is pending. |
| Enter | In the typed variant, confirms once the name matches. |
| Space | Held on a hold button, confirms after the duration. Releasing cancels. |
Components involved#
The parts this pattern is built from.
| Component | Role here |
|---|---|
| Toast | Past-tense confirmation with Undo. |
| Confirm dialog | Irreversible actions, with confirmText for the typed variant. |
| Hold button | Sustained-press confirmation inside live panels. |
| Button | The destructive red tint. |
| Dropdown menu | Destructive items last, after a separator. |
| Alert dialog | The primitive under Confirm dialog, for custom layouts. |