{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-action",
  "type": "registry:ui",
  "title": "Hold action",
  "description": "Protects high-risk actions with cancellable touch, pointer, and keyboard progress.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "actions"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/hold-action/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Hold progress completes in place or returns smoothly",
    "sceneFamily": "product-mono",
    "motionRole": "snap",
    "primaryState": "Hold-to-confirm button with active fill",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/hold-action.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/hold-action.tsx",
      "content": "\"use client\";\n\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\n\nexport type HoldActionPhase = \"idle\" | \"holding\" | \"releasing\" | \"complete\";\n\nexport type UseHoldActionOptions = {\n  onComplete: () => void;\n  onCancel?: () => void;\n  duration?: number;\n  moveTolerance?: number;\n  disabled?: boolean;\n};\n\nexport function useHoldAction({ onComplete, onCancel, duration = 1200, moveTolerance = 10, disabled = false }: UseHoldActionOptions) {\n  const [phase, setPhase] = useState<HoldActionPhase>(\"idle\");\n  const [progress, setProgress] = useState(0);\n  const phaseRef = useRef<HoldActionPhase>(\"idle\");\n  const frame = useRef(0);\n  const startedAt = useRef(0);\n  const origin = useRef<{ x: number; y: number } | null>(null);\n  const complete = useRef(onComplete);\n  const cancel = useRef(onCancel);\n  complete.current = onComplete;\n  cancel.current = onCancel;\n\n  const stop = useCallback((next: HoldActionPhase = \"idle\") => {\n    cancelAnimationFrame(frame.current);\n    frame.current = 0;\n    phaseRef.current = next;\n    setPhase(next);\n    setProgress(next === \"complete\" ? 1 : 0);\n    origin.current = null;\n  }, []);\n\n  const release = useCallback(() => {\n    if (phaseRef.current !== \"holding\") return;\n    cancel.current?.();\n    stop(\"releasing\");\n    window.setTimeout(() => phaseRef.current === \"releasing\" && stop(), 160);\n  }, [stop]);\n\n  const begin = useCallback((point?: { x: number; y: number }) => {\n    if (disabled || phaseRef.current === \"holding\" || phaseRef.current === \"complete\") return;\n    origin.current = point ?? null;\n    phaseRef.current = \"holding\";\n    setPhase(\"holding\");\n    setProgress(0);\n    startedAt.current = performance.now();\n\n    const tick = (now: number) => {\n      const next = Math.min(1, (now - startedAt.current) / duration);\n      setProgress(next);\n      if (next < 1) {\n        frame.current = requestAnimationFrame(tick);\n        return;\n      }\n      frame.current = 0;\n      phaseRef.current = \"complete\";\n      setPhase(\"complete\");\n      navigator.vibrate?.(12);\n      complete.current();\n    };\n    frame.current = requestAnimationFrame(tick);\n  }, [disabled, duration]);\n\n  useEffect(() => {\n    const abandon = () => release();\n    const visibility = () => document.hidden && release();\n    window.addEventListener(\"blur\", abandon);\n    document.addEventListener(\"visibilitychange\", visibility);\n    return () => {\n      window.removeEventListener(\"blur\", abandon);\n      document.removeEventListener(\"visibilitychange\", visibility);\n      cancelAnimationFrame(frame.current);\n    };\n  }, [release]);\n\n  return {\n    phase,\n    progress,\n    reset: () => stop(),\n    bind: {\n      onPointerDown: (event: React.PointerEvent) => {\n        if (event.pointerType === \"mouse\" && event.button !== 0) return;\n        event.currentTarget.setPointerCapture?.(event.pointerId);\n        begin({ x: event.clientX, y: event.clientY });\n      },\n      onPointerMove: (event: React.PointerEvent) => {\n        const start = origin.current;\n        if (!start || phaseRef.current !== \"holding\") return;\n        if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > moveTolerance) release();\n      },\n      onPointerUp: release,\n      onPointerCancel: release,\n      onPointerLeave: release,\n      onKeyDown: (event: React.KeyboardEvent) => {\n        if (event.key === \"Escape\") { event.preventDefault(); release(); return; }\n        if (!event.repeat && (event.key === \" \" || event.key === \"Enter\")) { event.preventDefault(); begin(); }\n      },\n      onKeyUp: (event: React.KeyboardEvent) => {\n        if (event.key === \" \" || event.key === \"Enter\") release();\n      },\n      onBlur: release,\n      onClick: (event: React.MouseEvent) => event.preventDefault(),\n      onContextMenu: (event: React.MouseEvent) => event.preventDefault(),\n    },\n  };\n}\n\nexport type HoldActionProps = {\n  children: React.ReactNode;\n  onComplete: () => void;\n  onCancel?: () => void;\n  duration?: number;\n  completeLabel?: string;\n  hint?: string;\n  resetAfter?: number;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function HoldAction({ children, onComplete, onCancel, duration = 1200, completeLabel = \"Complete\", hint, resetAfter = 1400, disabled = false, className = \"\" }: HoldActionProps) {\n  const { bind, phase, progress, reset } = useHoldAction({ onComplete, onCancel, duration, disabled });\n  const reduced = useReducedMotion() === true;\n  const hintId = useId();\n  const fill = useMotionValue(0);\n  const clipPath = useTransform(fill, (value) => `inset(0 ${(1 - value) * 100}% 0 0)`);\n  const completed = phase === \"complete\";\n\n  useEffect(() => {\n    const controls = animate(fill, progress, { duration: reduced ? 0 : phase === \"releasing\" ? 0.16 : 0.08, ease: \"linear\" });\n    return () => controls.stop();\n  }, [fill, phase, progress, reduced]);\n  useEffect(() => {\n    if (!completed || resetAfter <= 0) return;\n    const timer = window.setTimeout(reset, resetAfter);\n    return () => window.clearTimeout(timer);\n  }, [completed, reset, resetAfter]);\n\n  return (\n    <button type=\"button\" aria-disabled={disabled || completed} aria-describedby={hintId} {...bind} style={{ touchAction: \"manipulation\", WebkitTouchCallout: \"none\" }} className={`relative isolate inline-grid min-h-11 select-none place-items-center overflow-hidden rounded-[9px] border border-neutral-300 bg-white px-4 text-[12px] font-medium text-neutral-900 outline-none focus-visible:ring-2 focus-visible:ring-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/15 dark:bg-[#1b1b1b] dark:text-white ${className}`}>\n      <span className=\"relative z-10 grid\"><span className=\"col-start-1 row-start-1 whitespace-nowrap\">{completed ? completeLabel : children}</span></span>\n      <motion.span aria-hidden=\"true\" data-hold-fill style={{ clipPath }} className=\"absolute inset-0 bg-neutral-950 dark:bg-white\" />\n      <motion.span aria-hidden=\"true\" style={{ clipPath }} className=\"relative z-10 col-start-1 row-start-1 whitespace-nowrap text-white dark:text-neutral-950\">{completed ? completeLabel : children}</motion.span>\n      <span id={hintId} className=\"sr-only\">{hint ?? `Press and hold for ${Math.round(duration / 100) / 10} seconds. Releasing early cancels the action.`}</span>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">{completed ? completeLabel : \"\"}</span>\n    </button>\n  );\n}\n"
    }
  ]
}
