Buttons
Hold to Confirm
A press-and-hold confirmation button — a charge bar that replaces a risky one-tap action with a deliberate hold gesture.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/hold-to-confirm.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HoldToConfirm" component using
lucide-react icons (Check). No animation library — raw Pointer/Keyboard
events plus requestAnimationFrame and CSS transitions.
Contract
- forwardRef<HTMLButtonElement> extending React.ButtonHTMLAttributes, with
onClick omitted (a native click fires on any quick press+release — keeping
it would let consumers bypass the hold gesture entirely).
- Props: onConfirm: () => void (required, fires exactly once), duration = 1200
(ms the press must be held; 0, negative or non-finite falls back to 1200 —
0 would paint NaN% and a negative ratio never reaches 1), label = "Hold to confirm",
confirmedLabel = "Confirmed", tone: "default" | "destructive" = "default",
disabled?: boolean, className.
- Progress is internal gesture state, not a controlled prop. After confirming
the button locks; consumers reset by remounting it (key change) — same
convention as its sibling, slide-to-confirm.
Behavior
- pointerdown (or Space/Enter keydown) starts charging: record a start
timestamp and drive a requestAnimationFrame loop that computes
ratio = min((now - start) / duration, 1) every frame. High-frequency writes
bypass React state entirely — the loop paints straight onto two DOM nodes
via refs (a fill layer's width, a reveal-text layer's clip-path); only the
discrete phase change (charging → confirmed, or charging → idle on cancel)
goes through setState.
- Releasing early — pointerup, pointerleave, blur, or Space/Enter keyup —
before the ratio reaches 1 cancels the requestAnimationFrame and springs
the fill/reveal layers back to zero via a CSS transition (not JS-driven);
reaching ratio 1 stops the loop, fires onConfirm exactly once, and enters a
locked confirmed state (Check icon + confirmedLabel).
- Keyboard parity is mandatory: Space and Enter start/cancel the same charge.
Space calls preventDefault (stop page scroll). Held keys re-fire native
keydown repeatedly — guard with event.repeat so only the first keydown
starts a charge, not every repeat tick.
- Consumer onPointerDown / onPointerUp / onPointerLeave / onPointerCancel /
onKeyDown / onKeyUp / onBlur are destructured and called FIRST inside the
matching internal handler, never spread over it (a spread would silently
replace the gesture). The start paths (pointerdown, keydown) skip the charge
when the consumer called preventDefault; the cancel paths always run, so a
released press can never go on to confirm.
- Assistive-tech activation: screen readers that activate with a synthesized
click and no key or pointer events (NVDA / JAWS browse-mode Enter) cannot
hold. An internal onClick treats a click with event.detail === 0 that no
Space / Enter press produced (a ref armed on Enter keydown and Space keyup,
consumed by the click that key produces, disarmed on the next task if none
follows) as a request to charge: it runs the same timed
charge without a hold, and a second such activation or blur cancels it. A
visually-hidden aria-live="polite" region, a SIBLING of the button inside a
display:contents span (the ref stays on the button), says "Holding. Activate
again to cancel.", then confirmedLabel on confirm or "Cancelled.".
- disabled and the locked confirmed state both ignore pointer + keyboard
input; the native disabled attribute only reflects the consumer's prop
(confirmed locks via internal guards, not by disabling the element, so it
stays focusable and its label still announces).
Rendering & styling
- Pill button: relative overflow-hidden rounded-full px-6 py-2.5 text-sm
font-medium, touch-none select-none (a hold gesture must not trigger mobile
scroll/selection).
- Three stacked layers inside the button: (1) a base label (z-0) in the
track's text color; (2) a fill layer (z-10, absolute inset-y-0 left-0)
that is a solid color block whose width tracks the hold ratio; (3) a
reveal-text layer (z-20, absolute inset-0) — the same label recolored for
contrast against the fill, clipped via clip-path: inset(0 X% 0 0) where X
shrinks from 100% to 0% as the ratio grows, so the recolored text is
progressively unmasked exactly where the fill has covered it.
- Tokens: default tone = bg-primary/20 track, bg-primary fill,
text-primary-foreground reveal text; destructive tone swaps to
bg-destructive/20 / bg-destructive (reveal text stays
text-primary-foreground — this theme has no dedicated
destructive-foreground token). Focus ring color follows tone. Merge
consumer className via cn(). No hardcoded colors.
- Transitions: while charging, both layers use transition-none (the rAF loop
paints every frame itself — a CSS transition would fight it). Once charging
stops (cancel or confirm), a transition-[width,clip-path] duration-300
ease-out takes over for the snap-back / settle, with
motion-reduce:transition-none so reduced-motion users get an instant
return. The hold duration itself never changes under reduced motion —
only the decorative snap-back easing is removed, not the required hold
time.
Customization levers
- duration: raise it for scarier actions, lower it for lightweight ones —
it's the only knob controlling how long the gesture must be sustained.
- tone: swaps the track/fill token pair; add more tones by mapping onto your
own semantic color roles the same way.
- Progress direction: this is a linear left-to-right fill; for a circular
charge indicator instead, look at progress-meter (shape="ring") and drive its stroke
dashoffset from the same ratio value computed here.
- Haptic feedback: call navigator.vibrate(...) at charge start and/or on
confirm for devices that support it — purely additive, guard behind a
feature check since not all browsers implement it.
- Reset policy: lock-after-confirm is deliberate (anti double-fire). For
auto-reset flows, remount with a new key from the consumer instead of
adding internal timers.Concepts
- Hold as friction — the gesture costs sustained time, not a single tap; the required duration itself is the confirmation, so a stray tap or a slipped finger can never fire the action.
- rAF-driven, state-free progress — the charge loop paints width and clip-path directly onto DOM refs every frame instead of calling setState, so a 1200ms hold never triggers hundreds of re-renders; React only steps in for the discrete phase change.
- Cancel-retreat transition — releasing early stops the JS loop and hands off to a CSS transition that springs the fill back to zero, decoupling "how progress advances" (linear, JS) from "how it retreats" (eased, CSS).
- Clip-path text reveal — the recolored label lives in a second layer, clipped to the exact edge of the fill, so the label visually inverts contrast only where the solid fill has passed under it.
- Assistive-tech charge — a screen-reader activation arrives as a lone click with no key or pointer behind it, so it starts the same timed charge without a hold, announced through a sibling live region, and a second activation cancels it.
- Keyboard repeat guard — held keys fire native keydown repeatedly;
event.repeatensures only the first keydown starts a charge, keeping the mouse and keyboard paths identical. - Lock after confirm — a confirming control that can silently re-arm invites double-fires; the component locks and hands reset back to the consumer as an explicit remount, the same contract slide-to-confirm uses.