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.json

Usage

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

  1. Two-step state machine. The component holds a single armed boolean. Tap on rest → armed = true + countdown scheduled. Tap on armed → onConfirm() + reset. Timeout / blur / Escape → silent disarm.
  2. Label is the feedback. The label swaps from label to confirmLabel while 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.
  3. CVA variant × armed matrix. variant (ghost / solid) controls the rest-state surface treatment; armed (driven by internal state) re-paints both variants to the danger tone via compoundVariants.
  4. Countdown ring. When an icon is provided, an SVG ring around it traces from 0 → 1 over armDuration ms via motion.circle's pathLength. The ring is omitted when no icon is set — the tint and label swap carry the cue alone.
  5. Auto-disarm contract. setTimeout schedules the disarm at exactly armDuration; Escape and onBlur both call the same disarm() 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

PropTypeDefaultDescription
labelReactNodeRest-state label.
confirmLabelReactNodeArmed-state label. Shown after the first tap.
onConfirm() => voidFires on the second tap while armed.
armDurationnumber1800Window (ms) the button stays armed before silent disarm.
variant'ghost' | 'solid''ghost'Surface treatment for the rest state.
iconReactNodeOptional leading icon; countdown ring renders around it.
disabledbooleanfalseDisables the button and drops the tap gesture.
classNamestringMerged onto the rendered element via cn().
...restHTMLMotionProps<'button'>Any other motion.button prop.

Accessibility

  • Renders a real <button> — receives focus, fires on Enter / Space, joins form submission. Pass aria-label for icon-only renders.
  • aria-pressed reflects the armed state. aria-live="polite" on the root posts the label swap to assistive tech without interrupting the user.
  • Escape while armed disarms silently — keyboard users always have an out.
  • onBlur while 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-motionwhileTap is 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-specific playSound, a custom useTimers hook, and the legacy "tap-tap" name from a predecessor. craft-bits re-implements the namesake interaction — first tap arms, second tap within armDuration confirms — drops the sound coupling, re-paints through the cb-* token system, and routes both surface treatments through a CVA variant × armed matrix.