{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hold-to-confirm","type":"registry:component","title":"Hold to confirm","description":"A deliberate action, held for a moment: the ring fills, the label changes, and confirmation settles into a check.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/hold-to-confirm.tsx","type":"registry:component","content":"// Hairline UI · hold-to-confirm · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { CircleAlert, Fingerprint } from 'lucide-react';\nimport { TextMorph } from './text-morph';\nimport { INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useHoldConfirm, type HoldStatus } from './use-hold-confirm';\nexport type { HoldStatus } from './use-hold-confirm';\n\nexport interface HoldToConfirmProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'children'> {\n  onConfirm: () => void | Promise<void>;\n  duration?: number;\n  resetAfter?: number;\n  labels?: Partial<Record<HoldStatus, string>>;\n  icon?: React.ReactNode;\n}\nconst LABELS = { idle: 'Hold to confirm', holding: 'Keep holding', pending: 'Confirming', success: 'Confirmed', error: 'Hold to retry' };\n\n/** Press and keep holding. The threshold owns the action; a click never submits it. */\nexport function HoldToConfirm({ onConfirm, duration = 1100, resetAfter = 2200, labels, icon, disabled = false, className = '', ...props }: HoldToConfirmProps) {\n  const reduced = useInteractionReducedMotion();\n  const delay = Number.isFinite(duration) ? Math.max(300, Math.min(5000, duration)) : 1100;\n  const { status, progress, begin, cancel } = useHoldConfirm(onConfirm, delay, resetAfter, disabled, reduced);\n  const text = { ...LABELS, ...labels }[status];\n  const hint = React.useId();\n  const pending = status === 'pending', succeeded = status === 'success';\n  return <>\n    <button {...props} type=\"button\" disabled={disabled} aria-describedby={hint} aria-busy={pending || undefined} aria-disabled={pending || succeeded || disabled || undefined}\n      onPointerDown={(event) => {\n        if (event.button !== 0 || !event.isPrimary) return;\n        event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.focus({ preventScroll: true }); begin(`pointer:${event.pointerId}`);\n      }}\n      onPointerMove={(event) => {\n        if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;\n        const rect = event.currentTarget.getBoundingClientRect();\n        if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) cancel(`pointer:${event.pointerId}`);\n      }}\n      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()}\n      onKeyDown={(event) => {\n        if (event.key === 'Escape') { event.preventDefault(); cancel(); }\n        if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); if (!event.repeat) begin(`key:${event.key}`); }\n      }} onKeyUp={(event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); cancel(`key:${event.key}`); } }}\n      onClick={(event) => event.preventDefault()}\n      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}`}\n      style={{ touchAction: 'none', ...props.style }}>\n      <span aria-hidden=\"true\" className=\"relative size-5 shrink-0\">\n        <span className=\"absolute inset-0 flex items-center justify-center [&>svg]:size-4\" style={interactionIcon(status === 'idle' || status === 'holding', reduced)}>{icon ?? <Fingerprint />}</span>\n        <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' }}>\n          <circle cx=\"12\" cy=\"12\" r=\"10.5\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.2\" strokeWidth=\"1\" />\n          <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\" />\n        </svg>\n        <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>\n        <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>\n        <CircleAlert className=\"absolute inset-0 size-5\" style={interactionIcon(status === 'error', reduced)} />\n      </span>\n      <TextMorph>{text}</TextMorph>\n    </button>\n    <span id={hint} className=\"sr-only\">Hold the pointer, Space or Enter for {delay / 1000} seconds. Release early or press Escape to cancel.</span>\n    <span role=\"status\" className=\"sr-only\">{status === 'idle' ? '' : text}</span>\n  </>;\n}\n"},{"path":"registry/use-hold-confirm.ts","type":"registry:component","content":"// Hairline UI · hold-to-confirm · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { EASE_OUT } from './ui-interaction-motion';\n\nexport type HoldStatus = 'idle' | 'holding' | 'pending' | 'success' | 'error';\n\nexport function useHoldConfirm(onConfirm: () => void | Promise<void>, duration: number, resetAfter: number, disabled: boolean, reduced: boolean) {\n  const [status, setStatus] = React.useState<HoldStatus>('idle');\n  const phase = React.useRef<HoldStatus>('idle');\n  const progress = React.useRef<SVGCircleElement>(null);\n  const animation = React.useRef<Animation | null>(null);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const held = React.useRef(false);\n  const owner = React.useRef<string | null>(null);\n  const alive = React.useRef(false);\n  const action = React.useRef(onConfirm); action.current = onConfirm;\n  const setPhase = (next: HoldStatus) => { phase.current = next; setStatus(next); };\n  const cancel = (source?: string) => {\n    if (!held.current || (source && source !== owner.current)) return;\n    held.current = false;\n    if (timer.current) clearTimeout(timer.current);\n    const path = progress.current;\n    const from = path ? getComputedStyle(path).strokeDashoffset : '1';\n    animation.current?.cancel();\n    if (path && !reduced) animation.current = path.animate([{ strokeDashoffset: from }, { strokeDashoffset: '1' }], { duration: 240, easing: EASE_OUT });\n    setPhase('idle');\n  };\n  const begin = (source: string) => {\n    if (disabled || held.current || !['idle', 'error'].includes(phase.current)) return;\n    if (resetTimer.current) clearTimeout(resetTimer.current);\n    held.current = true; owner.current = source; setPhase('holding'); animation.current?.cancel();\n    if (progress.current) animation.current = progress.current.animate([{ strokeDashoffset: '1' }, { strokeDashoffset: '0' }], { duration, easing: 'linear', fill: 'forwards' });\n    timer.current = setTimeout(async () => {\n      if (!held.current || !alive.current) return;\n      held.current = false; setPhase('pending');\n      try { await action.current(); if (alive.current) setPhase('success'); }\n      catch { if (alive.current) setPhase('error'); }\n      finally {\n        animation.current?.cancel();\n        if (alive.current && resetAfter > 0) resetTimer.current = setTimeout(() => setPhase('idle'), resetAfter);\n      }\n    }, duration);\n  };\n  const cancelRef = React.useRef(cancel); cancelRef.current = cancel;\n  React.useEffect(() => {\n    alive.current = true;\n    const hidden = () => { if (document.hidden) cancelRef.current(); };\n    const blur = () => cancelRef.current();\n    window.addEventListener('blur', blur); document.addEventListener('visibilitychange', hidden);\n    return () => {\n      alive.current = false; held.current = false;\n      if (timer.current) clearTimeout(timer.current);\n      if (resetTimer.current) clearTimeout(resetTimer.current);\n      animation.current?.cancel();\n      window.removeEventListener('blur', blur); document.removeEventListener('visibilitychange', hidden);\n    };\n  }, []);\n  React.useEffect(() => { cancelRef.current(); }, [reduced]);\n  React.useEffect(() => { if (disabled) cancel(); }, [disabled]);\n  return { status, progress, begin, cancel };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · hold-to-confirm · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport 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';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  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}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/hold-to-confirm. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}