Destructive actions
Undo when you can, confirm when you must, and make irreversible actions deliberately hard to trigger by accident.
Deleting, revoking and resetting destroy work. The goal is to make recovery easy — and, where recovery is impossible, to make the mistake hard.
Undo over confirmation
A confirmation dialog on every delete trains people to click Yes without reading. If an action can be reversed, do it immediately and offer undo in a toast. It's faster for the 99% who meant it and safer for the 1% who didn't.
"use client"
import * as React from "react"
import { ArchiveIcon, InboxIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from "@/components/ui/item"
import { toast } from "@/components/ui/toast"
const initial = [
{ id: 1, title: "Invoice #2041 is ready", from: "Billing" },
{ id: 2, title: "Ada commented on “Homepage v3”", from: "Comments" },
{ id: 3, title: "Weekly usage report", from: "Reports" },
]
export default function UndoToast() {
const [items, setItems] = React.useState(initial)
function archive(id: number) {
const index = items.findIndex((item) => item.id === id)
const removed = items[index]
setItems((current) => current.filter((item) => item.id !== id))
const toastId = toast.add({
title: "Conversation archived",
actionProps: {
children: "Undo",
onClick: () => {
// Closed with the click: a second one would restore the row twice.
toast.close(toastId)
setItems((current) => {
const next = [...current]
next.splice(index, 0, removed)
return next
})
},
},
})
}
return (
<ItemGroup className="w-full max-w-md gap-2">
{items.map((item) => (
<Item key={item.id} variant="outline" size="sm">
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
<ItemDescription>{item.from}</ItemDescription>
</ItemContent>
<ItemActions>
{/* A word, not a lone icon: archiving isn't universal enough
to go unlabeled. The accessible name adds which message. */}
<Button
variant="ghost"
size="sm"
aria-label={`Archive “${item.title}”`}
onClick={() => archive(item.id)}
>
<ArchiveIcon data-icon="inline-start" />
Archive
</Button>
</ItemActions>
</Item>
))}
{items.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-sm text-muted-foreground">
<InboxIcon className="size-5" aria-hidden="true" />
Inbox zero.
<Button variant="link" size="sm" onClick={() => setItems(initial)}>
Restore examples
</Button>
</div>
)}
</ItemGroup>
)
}Confirm what can't be undone
When an action is irreversible — deleting a project, revoking keys, removing a member's access — confirm with an AlertDialog:
- Name the object in the title: Delete “acme-web”?
- State the consequence in the description: what will be lost, and that it can't be undone.
- Label buttons with verbs. Delete project and Keep project — never Yes and No.
- Use the
destructivevariant only on the confirming button. - For high-stakes actions, require typing the name. It turns a reflexive click into a deliberate act.
"use client"
import * as React from "react"
import { Trash2Icon } from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
const PROJECT = "acme-web"
export default function DestructiveConfirm() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
const [deleting, setDeleting] = React.useState(false)
async function confirm() {
setDeleting(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
setDeleting(false)
setOpen(false)
setValue("")
toast.add({
title: `Deleted ${PROJECT}`,
description: "14 deployments and 3 domains were removed.",
})
}
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
// The dialog stays until the request settles: closing wouldn't stop it.
if (deleting) return
setOpen(next)
// Reopened, it asks for the name again.
if (!next) setValue("")
}}
>
{/* The solid destructive variant is kept for the button that confirms. */}
<AlertDialogTrigger
render={
<Button
variant="outline"
className="text-danger-11 hover:text-danger-11"
/>
}
>
<Trash2Icon data-icon="inline-start" />
Delete project
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia variant="destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>Delete “{PROJECT}”?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes 14 deployments and 3 domains. It can't
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<Field>
<FieldLabel htmlFor="confirm-name">
Type <span className="font-mono">{PROJECT}</span> to confirm
</FieldLabel>
<Input
id="confirm-name"
autoComplete="off"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</Field>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
Keep project
</AlertDialogCancel>
<Button
variant="destructive"
disabled={value !== PROJECT}
loading={deleting}
onClick={confirm}
>
Delete project
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Placement
- Keep destructive actions away from frequent ones. A Delete button next to Save will eventually be clicked by mistake.
- In menus, put destructive items last, after a separator, using
variant="destructive". - On settings pages, group them in a danger zone at the end of the page.
Delete “acme-web”?
14 deployments will be removed. This can’t be undone.
Are you sure?
Please confirm.