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
"use client"

import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"

type Phase = "idle" | "charging" | "confirmed"

/** What started a charge. "click" is an assistive-tech activation (see handleClick). */
type ChargeSource = "pointer" | "key" | "click"

const DEFAULT_DURATION = 1200

const ACTIVATE_KEYS = new Set([" ", "Enter"])

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/hold-to-confirm.json

Prompt

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.repeat ensures 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.

On This Page