Two-Step Button

Needs two deliberate clicks for a destructive action: the first arms it with a draining timer, the second confirms.

StopsOne click deleted without confirmation

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/two-step-button.json

The files land in your project. They're yours to edit.

Usage

two-step-button-usage.tsx
import { TwoStepButton } from "@/components/ui/two-step-button"

<TwoStepButton
  armedLabel="Click again to delete"
  pendingLabel="Deleting…"
  confirmedLabel="Deleted"
  onConfirm={() =>
    new Promise<void>((resolve) =>
      setTimeout(() => {
        setLog("Project deleted.")
        resolve()
      }, 900),
    )
  }
>
  <Trash2 aria-hidden className="size-4" /> Delete project
</TwoStepButton>

What it catches

  • A single accidental click (only arms)
  • Double-clicks faster than guard ms
  • Held Enter (auto-repeat)
  • Losing focus, Escape or timeout, which disarm

Props

TwoStepButton props
PropTypeDefault
onConfirm* (required)

Runs on the confirming click. Return a promise to show a pending state until it settles.

() => void | Promise<void>—
timeout

How long the button stays armed, in milliseconds.

number3000
guard

Second clicks sooner than this (ms) are ignored as an accidental double-click.

number300
variant

Visual intent of the idle button. The armed state is always destructive.

keyof typeof variants"destructive"
size

Size of the button.

keyof typeof sizes"default"
armedLabel

Label while armed.

React.ReactNode"Click again to confirm"
armedHint

Read to screen readers when the button arms.

string"Press again to confirm, or Escape to cancel."
pendingLabel

Label while an async `onConfirm` is running.

React.ReactNode"Working…"
confirmedLabel

Label shown for a moment after the action is confirmed.

React.ReactNode"Done"
resetAfter

Milliseconds before the button resets after confirming. 0 keeps it confirmed.

number1600

* required. Also accepts the native props of <button> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Arming is announced via a polite live region (armedHint)
  • Escape disarms; held Enter is ignored
  • aria-busy while an async confirm is pending
  • Label changes are text, not colour alone

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

  • A deliberate second click still confirms; it is not a full review step
  • UI guard only, not authorization

What it stops

One click deleted without confirmation

Also known as

confirm button · click twice · arm button · inline confirm · delete button · are you sure · destructive button

Dependencies

Pairs well with