{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-confirm",
  "type": "registry:ui",
  "title": "Hold to confirm",
  "description": "Confirm a destructive action by holding, not tapping.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/hold-to-confirm/hold-to-confirm.tsx",
      "type": "registry:component",
      "target": "@components/xzero/hold-to-confirm/hold-to-confirm.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useEffectEvent, useId, useRef, useState } from \"react\";\nimport type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue, TargetAndTransition } from \"motion/react\";\nimport { Trash2 } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./hold-to-confirm.module.css\";\n\n/**\n * A button that commits only after it is held, for destructive or hard to undo actions where a stray tap must not count. A fill tracks the hold on a\n * linear timeline; letting go early rewinds it on a spring, and finishing morphs the label and icon into a done state. Space and Enter can be held too.\n * Prefer a regular confirmation dialog when people need to read consequences first; use this when the consequence is already on screen.\n */\nexport interface HoldToConfirmProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onClick\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\" | \"onAnimationEnd\"> {\n  /** The instruction and the action, for example “Hold to delete project”. */\n  label: string;\n  /** Shown once the hold completes, for example “Deleted”. */\n  confirmedLabel?: string;\n  /** Called once when the hold completes. */\n  onConfirm: () => void;\n  /** Hold length in milliseconds. */\n  duration?: number;\n  icon?: ReactNode;\n  /** `accent` (default) fills with the selected accent; `danger` is for an irreversible action; `neutral` fills with the foreground. */\n  tone?: \"accent\" | \"danger\" | \"neutral\";\n  /** Controls the done state. Set it back to false to reset the button; leave it undefined to let the button keep its own state. */\n  confirmed?: boolean;\n  /** Reports when a hold starts and ends, for surrounding hints such as “Keep holding”. */\n  onHoldChange?: (holding: boolean) => void;\n}\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };\nconst fadeIn: TargetAndTransition = { opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };\n\n/** The tick draws itself from its short stroke, the way a hand would write it. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return <svg width={18} height={18} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <motion.path d=\"M4 12.5l5 5L20 6.5\" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: .32, ease: enter, delay: .08 }, opacity: { duration: .05, delay: .08 } }} />\n  </svg>;\n}\n\ntype FaceProps = { icon: ReactNode; text: string; done: boolean; width: MotionValue<number | \"auto\">; reduced: boolean; measure?: (node: HTMLSpanElement | null) => void };\n\n/** Icon and label. The button renders it twice: once on the surface and once inside the fill, so the text changes colour exactly at the fill edge. */\nfunction Face({ icon, text, done, width, reduced, measure }: FaceProps) {\n  return <span className={styles.face}>\n    <span className={styles.iconSlot}>\n      <AnimatePresence initial={false}>\n        <motion.span key={done ? \"done\" : \"idle\"} className={styles.iconPhase} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}>\n          {done ? <DrawnCheck reduced={reduced} /> : icon}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n    <motion.span className={styles.labelFrame} style={{ width }}>\n      {measure && <span ref={measure} className={styles.measure}>{text}</span>}\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span key={text} className={styles.label} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>{text}</motion.span>\n      </AnimatePresence>\n    </motion.span>\n  </span>;\n}\n\n/** Springs the label frame to the width of new text, so the button morphs instead of snapping. A late web font or a reflow follows instantly. */\nfunction useLabelWidth(reduced: boolean) {\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const [node, setNode] = useState<HTMLSpanElement | null>(null);\n  useEffect(() => {\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let lastText: string | null = null;\n    let sizing: AnimationPlaybackControls | null = null;\n    const observer = new ResizeObserver(([entry]) => {\n      // Layout width, not the painted box: the done label is measured while the pressed button is still scaled down.\n      const next = entry?.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      const text = node.textContent;\n      const morph = lastText !== null && lastText !== text && !reduced && typeof width.get() === \"number\";\n      lastText = text;\n      sizing?.stop();\n      if (morph) sizing = animate(width, next, motionTokens.spring.morph);\n      else width.jump(next);\n    });\n    observer.observe(node);\n    return () => { observer.disconnect(); sizing?.stop(); };\n  }, [node, reduced, width]);\n  return [width, setNode] as const;\n}\n\nexport function HoldToConfirm({ label, confirmedLabel = \"Done\", onConfirm, duration = 1200, icon = <Trash2 strokeWidth={1.75} />, tone = \"accent\", confirmed, onHoldChange, className, disabled, ...props }: HoldToConfirmProps) {\n  const reduced = useReducedMotion() ?? false;\n  const hintId = useId();\n  const [ownDone, setOwnDone] = useState(false);\n  const [completions, setCompletions] = useState(0);\n  const done = confirmed ?? ownDone;\n  const [holding, setHolding] = useState(false);\n  const progress = useMotionValue(0);\n  const scale = useMotionValue(1);\n  const clipPath = useTransform(progress, value => `inset(0 ${((1 - Math.min(1, Math.max(0, value))) * 100).toFixed(3)}% 0 0)`);\n  const [width, measure] = useLabelWidth(reduced);\n  const source = useRef<\"pointer\" | \"key\" | null>(null);\n  const pointerType = useRef(\"mouse\");\n  const fill = useRef<AnimationPlaybackControls | null>(null);\n  const press = useRef<AnimationPlaybackControls | null>(null);\n  const button = useRef<HTMLButtonElement>(null);\n  const text = done ? confirmedLabel : label;\n  const seconds = (duration / 1000).toLocaleString(\"en-US\", { maximumFractionDigits: 1 });\n\n  function pressTo(pressed: boolean) {\n    press.current?.stop();\n    if (reduced) { scale.jump(1); return; }\n    const depth = (button.current?.offsetWidth ?? 0) > 220 ? .985 : .97;\n    press.current = animate(scale, pressed ? depth : 1, motionTokens.spring.snappy);\n  }\n\n  function rewind() {\n    fill.current?.stop();\n    if (reduced) progress.jump(0);\n    else fill.current = animate(progress, 0, { ...motionTokens.spring.smooth, velocity: 0 });\n  }\n\n  function stopHolding() {\n    source.current = null;\n    setHolding(false);\n    onHoldChange?.(false);\n    pressTo(false);\n  }\n\n  function complete() {\n    if (!source.current) return;\n    stopHolding();\n    if (pointerType.current === \"touch\") navigator.vibrate?.(12);\n    setOwnDone(true);\n    setCompletions(count => count + 1);\n    onConfirm();\n  }\n\n  /** Starts or resumes the fill from wherever it is, so a quick re-press after letting go carries on instead of starting over. */\n  function begin(from: \"pointer\" | \"key\") {\n    if (done || disabled || source.current) return;\n    source.current = from;\n    setHolding(true);\n    onHoldChange?.(true);\n    pressTo(true);\n    fill.current?.stop();\n    fill.current = animate(progress, 1, { duration: (1 - progress.get()) * duration / 1000, ease: \"linear\", onComplete: complete });\n  }\n\n  function release() {\n    if (!source.current) return;\n    stopHolding();\n    rewind();\n  }\n\n  // A reset rewinds the fill as the label morphs back; a confirmation the parent declined rewinds too.\n  const syncFill = useEffectEvent(() => {\n    if (done && progress.get() < 1 && !source.current) { fill.current?.stop(); if (reduced) progress.jump(1); else fill.current = animate(progress, 1, motionTokens.spring.smooth); }\n    if (!done && progress.get() > 0 && !source.current) rewind();\n  });\n  useEffect(() => { syncFill(); }, [done, completions]);\n  useEffect(() => () => { fill.current?.stop(); press.current?.stop(); }, []);\n\n  function onPointerDown(event: ReactPointerEvent<HTMLButtonElement>) {\n    if (!event.isPrimary || event.button !== 0) return;\n    pointerType.current = event.pointerType;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    begin(\"pointer\");\n  }\n  function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>) {\n    if (source.current !== \"pointer\") return;\n    // Sliding well off the button cancels, the way a native press does.\n    const box = event.currentTarget.getBoundingClientRect(), slack = 24;\n    if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) release();\n  }\n  function onKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (event.key !== \" \" && event.key !== \"Enter\") return;\n    event.preventDefault();\n    if (!event.repeat) begin(\"key\");\n  }\n  function onKeyUp(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (event.key !== \" \" && event.key !== \"Enter\") return;\n    event.preventDefault();\n    if (source.current === \"key\") release();\n  }\n\n  return <>\n    <motion.button {...props} ref={button} type=\"button\" className={[styles.button, className].filter(Boolean).join(\" \")} data-tone={tone} data-state={done ? \"done\" : holding ? \"holding\" : \"idle\"}\n      disabled={disabled} aria-disabled={done || undefined} aria-label={text} aria-describedby={done ? undefined : hintId} style={{ scale }}\n      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={release} onPointerCancel={release} onLostPointerCapture={release}\n      onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={release} onContextMenu={event => event.preventDefault()}>\n      <Face icon={icon} text={text} done={done} width={width} reduced={reduced} measure={measure} />\n      <motion.span className={styles.fill} style={{ clipPath }} aria-hidden=\"true\">\n        <Face icon={icon} text={text} done={done} width={width} reduced={reduced} />\n      </motion.span>\n    </motion.button>\n    <span id={hintId} className={styles.srOnly}>{`Press and hold for ${seconds} seconds to confirm. With a keyboard, hold Space or Enter.`}</span>\n    <span className={styles.srOnly} role=\"status\">{done ? confirmedLabel : \"\"}</span>\n  </>;\n}\n\nexport default HoldToConfirm;\n"
    },
    {
      "path": "registry/components/hold-to-confirm/hold-to-confirm.module.css",
      "type": "registry:component",
      "target": "@components/xzero/hold-to-confirm/hold-to-confirm.module.css",
      "content": "/* The button hugs its label; the fill is a second, inverted copy of the face revealed by clip-path, so the text flips colour exactly at the edge. */\n/* The default tone follows the selected accent; danger stays available for a truly irreversible action. */\n.button { --hold-ink: var(--foreground); --hold-fill: var(--accent); --hold-on-fill: var(--accent-foreground); --hold-border: var(--border-strong); position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; padding: 0 var(--space-5); border: 1px solid var(--hold-border); border-radius: var(--radius-control); background: var(--surface); color: var(--hold-ink); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n.button[data-tone=\"danger\"] { --hold-ink: var(--danger); --hold-fill: var(--danger); --hold-on-fill: var(--background); --hold-border: color-mix(in oklab, var(--danger) 32%, var(--border)); }\n.button[data-tone=\"neutral\"] { --hold-ink: var(--foreground); --hold-fill: var(--foreground); --hold-on-fill: var(--background); --hold-border: var(--border-strong); }\n@media (hover: hover) and (pointer: fine) { .button:not(:disabled):not([aria-disabled=\"true\"]):hover { background: color-mix(in oklab, var(--hold-fill) 6%, var(--surface)); } }\n.button[aria-disabled=\"true\"] { cursor: default; }\n.button:disabled { cursor: not-allowed; opacity: .5; }\n.face { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }\n/* Covers the border too, and keeps its own rounded corners; the clip only ever cuts a straight leading edge. */\n.fill { position: absolute; inset: -1px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-control); background: var(--hold-fill); color: var(--hold-on-fill); pointer-events: none; }\n.iconSlot { display: grid; width: 18px; height: 18px; flex: none; place-items: center; }\n.iconPhase { display: grid; grid-area: 1 / 1; place-items: center; }\n.iconPhase svg { width: 18px; height: 18px; }\n/* Clips the outgoing label while the frame narrows, with room for the rise and the soft blur. */\n.labelFrame { position: relative; display: inline-flex; min-width: 0; clip-path: inset(-.6em -3px); }\n.label { display: block; white-space: nowrap; }\n.measure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/hold-to-confirm",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "hold",
      "long press",
      "confirm"
    ],
    "docs": "https://ui.xzero.co.uk/components/hold-to-confirm",
    "markdown": "https://ui.xzero.co.uk/components/hold-to-confirm/markdown"
  }
}