Hold to confirm

A deliberate action, held for a moment: the ring fills, the label changes, and confirmation settles into a check.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/hold-to-confirm.json

Overview

Give an important action a little intention without opening another dialog. Press and hold while a fine progress ring traces around the icon. Release early and it returns to rest. Cross the threshold and the same button moves through pending, success or error, with a morphing label and drawn check. The action runs once, and an error can be retried with another hold.

States

hold
The icon ring fills linearly over the configured threshold (1100ms by default). A short press does not confirm.
release
Early release, Escape, focus loss or pointer interruption cancels the hold. The ring resets over 240ms.
pending
After the threshold, the callback runs once. The label morphs and the progress icon gives way to a spinner.
success / error
A resolved callback draws a check; a rejected callback shows an alert and offers another hold. The button resets after 2200ms by default.

Usage

usage.tsx
import { HoldToConfirm } from "@/components/hold-to-confirm";<HoldToConfirm  labels={{ idle: "Hold to archive", pending: "Archiving", success: "Archived" }}  onConfirm={async () => { await archiveProject(); }}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Connect a deliberate action

The callback owns the actual operation. Reject its promise to show an error; the component never invents a successful result.

example.tsx
"use client";import { HoldToConfirm } from "@/components/hold-to-confirm";export function ArchiveProject({ id }: { id: string }) {  return <HoldToConfirm labels={{ idle: "Hold to archive", success: "Archived" }}    onConfirm={async () => {      const response = await fetch("/api/projects/" + id + "/archive", { method: "POST" });      if (!response.ok) throw new Error("Archive failed");    }} />;}

Props

PropTypeDescription
onConfirm() => void | Promise<void>Runs once after the hold threshold. Resolves to success or rejects to error.
durationnumberdefault 1100Required hold in milliseconds, clamped to 300–5000.
resetAfternumberdefault 2200Delay before returning to idle. Set 0 to keep the result visible.
labelsPartial<Record<HoldStatus, string>>Labels for idle, holding, pending, success and error.
iconReactNodeIdle and holding icon. Defaults to a fingerprint.
disabledbooleandefault falseDisables the action and cancels an active hold.

Accessibility and motion

  • Hold the pointer, Space or Enter to confirm. Key repeat does not restart the timer, and a normal click never triggers the callback.
  • Releasing early, leaving the button, losing focus, Escape or a hidden browser tab cancels an incomplete hold. A completed action continues through its promise.
  • State announcements live outside the button. The button retains focus while pending or showing its result.
  • Reduced motion removes icon movement and spinning. The functional progress ring still communicates the hold duration.
  • Use clear action labels and provide undo in your application where appropriate.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph.

Source

components/hold-to-confirm.tsx
'use client';import * as React from 'react';import { CircleAlert, Fingerprint } from 'lucide-react';import { TextMorph } from './text-morph';import { INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from './ui-interaction-motion';import { useHoldConfirm, type HoldStatus } from './use-hold-confirm';export type { HoldStatus } from './use-hold-confirm';export interface HoldToConfirmProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'children'> {  onConfirm: () => void | Promise<void>;  duration?: number;  resetAfter?: number;  labels?: Partial<Record<HoldStatus, string>>;  icon?: React.ReactNode;}const LABELS = { idle: 'Hold to confirm', holding: 'Keep holding', pending: 'Confirming', success: 'Confirmed', error: 'Hold to retry' };/** Press and keep holding. The threshold owns the action; a click never submits it. */export function HoldToConfirm({ onConfirm, duration = 1100, resetAfter = 2200, labels, icon, disabled = false, className = '', ...props }: HoldToConfirmProps) {  const reduced = useInteractionReducedMotion();  const delay = Number.isFinite(duration) ? Math.max(300, Math.min(5000, duration)) : 1100;  const { status, progress, begin, cancel } = useHoldConfirm(onConfirm, delay, resetAfter, disabled, reduced);  const text = { ...LABELS, ...labels }[status];  const hint = React.useId();  const pending = status === 'pending', succeeded = status === 'success';  return <>    <button {...props} type="button" disabled={disabled} aria-describedby={hint} aria-busy={pending || undefined} aria-disabled={pending || succeeded || disabled || undefined}      onPointerDown={(event) => {        if (event.button !== 0 || !event.isPrimary) return;        event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.focus({ preventScroll: true }); begin(`pointer:${event.pointerId}`);      }}      onPointerMove={(event) => {        if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;        const rect = event.currentTarget.getBoundingClientRect();        if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) cancel(`pointer:${event.pointerId}`);      }}      onPointerUp={(event) => cancel(`pointer:${event.pointerId}`)} onPointerCancel={(event) => cancel(`pointer:${event.pointerId}`)} onLostPointerCapture={(event) => cancel(`pointer:${event.pointerId}`)} onPointerLeave={(event) => cancel(`pointer:${event.pointerId}`)} onBlur={() => cancel()}      onKeyDown={(event) => {        if (event.key === 'Escape') { event.preventDefault(); cancel(); }        if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); if (!event.repeat) begin(`key:${event.key}`); }      }} onKeyUp={(event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); cancel(`key:${event.key}`); } }}      onClick={(event) => event.preventDefault()}      className={`cursor-pointer disabled:cursor-default aria-disabled:cursor-default relative inline-flex h-11 select-none items-center justify-center gap-2.5 overflow-clip rounded-full border px-4 text-[13px] font-medium transition-colors disabled:opacity-40 ${status === 'error' ? 'border-destructive/40 bg-destructive/5 text-destructive' : 'border-primary bg-primary text-primary-foreground hover:bg-primary/90'} ${INTERACTION_FOCUS} ${className}`}      style={{ touchAction: 'none', ...props.style }}>      <span aria-hidden="true" className="relative size-5 shrink-0">        <span className="absolute inset-0 flex items-center justify-center [&>svg]:size-4" style={interactionIcon(status === 'idle' || status === 'holding', reduced)}>{icon ?? <Fingerprint />}</span>        <svg viewBox="0 0 24 24" className="absolute -inset-1 size-7 -rotate-90" style={{ opacity: status === 'holding' ? 1 : 0, transition: reduced ? 'none' : 'opacity 180ms ease-out' }}>          <circle cx="12" cy="12" r="10.5" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="1" />          <circle ref={progress} cx="12" cy="12" r="10.5" pathLength="1" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeDasharray="1" strokeDashoffset="1" />        </svg>        <span className="absolute inset-0 flex items-center justify-center" style={interactionIcon(pending, reduced)}><span className={`size-4 rounded-full border-[1.5px] border-current/30 border-t-current ${pending && !reduced ? 'animate-spin' : ''}`} /></span>        <svg viewBox="0 0 20 20" className="absolute inset-0 size-5" style={interactionIcon(succeeded, reduced)}><path d="m4 10 4 4 8-8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" pathLength="1" style={{ strokeDasharray: 1, strokeDashoffset: succeeded ? 0 : 1, transition: reduced ? 'none' : 'stroke-dashoffset 380ms ease-out 80ms' }} /></svg>        <CircleAlert className="absolute inset-0 size-5" style={interactionIcon(status === 'error', reduced)} />      </span>      <TextMorph>{text}</TextMorph>    </button>    <span id={hint} className="sr-only">Hold the pointer, Space or Enter for {delay / 1000} seconds. Release early or press Escape to cancel.</span>    <span role="status" className="sr-only">{status === 'idle' ? '' : text}</span>  </>;}
components/use-hold-confirm.ts
'use client';import * as React from 'react';import { EASE_OUT } from './ui-interaction-motion';export type HoldStatus = 'idle' | 'holding' | 'pending' | 'success' | 'error';export function useHoldConfirm(onConfirm: () => void | Promise<void>, duration: number, resetAfter: number, disabled: boolean, reduced: boolean) {  const [status, setStatus] = React.useState<HoldStatus>('idle');  const phase = React.useRef<HoldStatus>('idle');  const progress = React.useRef<SVGCircleElement>(null);  const animation = React.useRef<Animation | null>(null);  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);  const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);  const held = React.useRef(false);  const owner = React.useRef<string | null>(null);  const alive = React.useRef(false);  const action = React.useRef(onConfirm); action.current = onConfirm;  const setPhase = (next: HoldStatus) => { phase.current = next; setStatus(next); };  const cancel = (source?: string) => {    if (!held.current || (source && source !== owner.current)) return;    held.current = false;    if (timer.current) clearTimeout(timer.current);    const path = progress.current;    const from = path ? getComputedStyle(path).strokeDashoffset : '1';    animation.current?.cancel();    if (path && !reduced) animation.current = path.animate([{ strokeDashoffset: from }, { strokeDashoffset: '1' }], { duration: 240, easing: EASE_OUT });    setPhase('idle');  };  const begin = (source: string) => {    if (disabled || held.current || !['idle', 'error'].includes(phase.current)) return;    if (resetTimer.current) clearTimeout(resetTimer.current);    held.current = true; owner.current = source; setPhase('holding'); animation.current?.cancel();    if (progress.current) animation.current = progress.current.animate([{ strokeDashoffset: '1' }, { strokeDashoffset: '0' }], { duration, easing: 'linear', fill: 'forwards' });    timer.current = setTimeout(async () => {      if (!held.current || !alive.current) return;      held.current = false; setPhase('pending');      try { await action.current(); if (alive.current) setPhase('success'); }      catch { if (alive.current) setPhase('error'); }      finally {        animation.current?.cancel();        if (alive.current && resetAfter > 0) resetTimer.current = setTimeout(() => setPhase('idle'), resetAfter);      }    }, duration);  };  const cancelRef = React.useRef(cancel); cancelRef.current = cancel;  React.useEffect(() => {    alive.current = true;    const hidden = () => { if (document.hidden) cancelRef.current(); };    const blur = () => cancelRef.current();    window.addEventListener('blur', blur); document.addEventListener('visibilitychange', hidden);    return () => {      alive.current = false; held.current = false;      if (timer.current) clearTimeout(timer.current);      if (resetTimer.current) clearTimeout(resetTimer.current);      animation.current?.cancel();      window.removeEventListener('blur', blur); document.removeEventListener('visibilitychange', hidden);    };  }, []);  React.useEffect(() => { cancelRef.current(); }, [reduced]);  React.useEffect(() => { if (disabled) cancel(); }, [disabled]);  return { status, progress, begin, cancel };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/hold-to-confirm.json