Skip to content

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.

Invoice #2041 is ready
Ada commented on “Homepage v3”
Weekly usage report
"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 destructive variant 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&apos;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.

DoThe consequence is explicit and the button says what it does.

Are you sure?

Please confirm.

Don’t“Are you sure?” asks people to reread the screen to find out what they’re confirming.