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…

"use client"

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

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

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

export interface HoldToConfirmProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
  /**
   * Fires exactly once, the instant the hold reaches `duration`.

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), 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.
- 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.
  • 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