Two Tap Confirm Button
A friction-by-design button for destructive actions. The first tap arms the
button — the label swaps from label to confirmLabel, the surface re-tints
to the danger tone, and a countdown ring traces out over armDuration ms.
The second tap within that window fires onConfirm; outside it (timeout,
blur, Escape) the button disarms silently. Cheaper than a modal, gentler than
a hold gesture, and always ≥ 44 × 44 px so the second tap is still easy.
Preview
Fired 00 times
Customize
Button
0
1800
Installation
npx shadcn@latest add https://craftbits.dev/r/two-tap-confirm-button.jsonUsage
import { TwoTapConfirmButton } from "@craft-bits/core";
<TwoTapConfirmButton
label="Delete"
confirmLabel="Tap again to delete"
onConfirm={deleteItem}
aria-label="Delete item"
/>
<TwoTapConfirmButton
variant="solid"
label="Reset progress"
confirmLabel="Confirm reset"
armDuration={2400}
onConfirm={resetProgress}
/>Understanding the component
- Two-step state machine. The component holds a single
armedboolean. Tap on rest →armed = true+ countdown scheduled. Tap on armed →onConfirm()+ reset. Timeout / blur / Escape → silent disarm. - Label is the feedback. The label swaps from
labeltoconfirmLabelwhile armed. Combined with the tint flip and the countdown ring the user has three independent cues that they're one tap away from a destructive action — without ever blocking the rest of the UI. - CVA
variant × armedmatrix.variant(ghost/solid) controls the rest-state surface treatment;armed(driven by internal state) re-paints both variants to the danger tone viacompoundVariants. - Countdown ring. When an
iconis provided, an SVG ring around it traces from 0 → 1 overarmDurationms viamotion.circle'spathLength. The ring is omitted when no icon is set — the tint and label swap carry the cue alone. - Auto-disarm contract.
setTimeoutschedules the disarm at exactlyarmDuration;EscapeandonBlurboth call the samedisarm()so the state machine has a single exit path. The timer is cleared on unmount and on every state transition so it can never fire stale.
Variants
// Surface treatment
<TwoTapConfirmButton variant="ghost" label="Delete" confirmLabel="Tap again to delete" onConfirm={fn} />
<TwoTapConfirmButton variant="solid" label="Delete" confirmLabel="Confirm" onConfirm={fn} />
// Custom arm window
<TwoTapConfirmButton
label="Reset"
confirmLabel="Confirm reset"
armDuration={2400}
onConfirm={fn}
/>
// With a leading icon → countdown ring renders around it
<TwoTapConfirmButton
label="Clear"
confirmLabel="Tap again to clear"
icon={<TrashIcon />}
onConfirm={fn}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Rest-state label. |
confirmLabel | ReactNode | — | Armed-state label. Shown after the first tap. |
onConfirm | () => void | — | Fires on the second tap while armed. |
armDuration | number | 1800 | Window (ms) the button stays armed before silent disarm. |
variant | 'ghost' | 'solid' | 'ghost' | Surface treatment for the rest state. |
icon | ReactNode | — | Optional leading icon; countdown ring renders around it. |
disabled | boolean | false | Disables the button and drops the tap gesture. |
className | string | — | Merged onto the rendered element via cn(). |
...rest | HTMLMotionProps<'button'> | — | Any other motion.button prop. |
Accessibility
- Renders a real
<button>— receives focus, fires on Enter / Space, joins form submission. Passaria-labelfor icon-only renders. aria-pressedreflects thearmedstate.aria-live="polite"on the root posts the label swap to assistive tech without interrupting the user.Escapewhile armed disarms silently — keyboard users always have an out.onBlurwhile armed also disarms — a hot destructive control never outlives the user's focus.- Minimum hit area:
min-h-[2.75rem] min-w-[2.75rem](44 × 44 px), meeting WCAG 2.5.8 even in icon-only renders. - Focus ring uses
var(--cb-error)(matching the armed tint) for contrast across themes. - Motion respects
prefers-reduced-motion—whileTapis suppressed; the countdown ring still completes on time (it's a state cue, not decoration).
Credits
- Extracted from:
algoflashcards(src/platform/ui/TwoTapConfirmButton.tsx). The original was a press-and-hold gesture wired into a project-specificplaySound, a customuseTimershook, and the legacy "tap-tap" name from a predecessor. craft-bits re-implements the namesake interaction — first tap arms, second tap withinarmDurationconfirms — drops the sound coupling, re-paints through thecb-*token system, and routes both surface treatments through a CVAvariant × armedmatrix.