{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-button",
  "type": "registry:component",
  "title": "Hold Button — press-and-hold to commit",
  "description": "KokonutUI hold-to-confirm, bridged to the contract: deliberate intent sustained into effect. Two registers (default --color, danger --red); cva and the five-color variant map dropped; onHoldComplete added (upstream filled to 100% and did nothing).",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://design.subconscious.ai/r/utils.json",
    "button"
  ],
  "files": [
    {
      "path": "registry/components/hold-button.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Hold Button\n * @version: 1.0.0\n * @date: 2025-06-26\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n *\n * Bridged to the Subconscious design contract (DECISIONS.md §doctrine filter —\n * take the engineering, replace the surface):\n *\n *  - Kept: the press-and-hold commitment loop — pointer + touch handling, the\n *    linear fill driven by animation controls, release-to-abort. Deliberate\n *    intent sustained into effect is the most causal button interaction there\n *    is, which is why this component made the cut.\n *  - Re-mapped: the five-color cva variant map collapses to two registers —\n *    default (`--color`) and `danger` (`--red`, for destructive commitments;\n *    red is a signal, not a palette). cva dropped entirely;\n *    `class-variance-authority` is not a registry dependency.\n *  - Kept: the lucide icon — kokonut character stays (DECISIONS.md §doctrine\n *    filter: re-map the paint, don't strip the character). Danger shows the\n *    upstream Trash2; default shows MousePointerClick, the press-affordance\n *    icon this registry already speaks via particle-button. `icon={null}`\n *    removes it.\n *  - Added: `onHoldComplete`. Upstream fills to 100% and then nothing happens;\n *    the entire point of hold-to-confirm is the commit. Guarded by a hold\n *    token so a release mid-fill (framer resolves stopped animations) cannot\n *    fire a stale completion.\n *\n * Reduced motion: the fill is tier-1 essential feedback (progress toward an\n * irreversible action), so it animates regardless — DOCTRINE.md §4.\n */\n\nimport { motion, useAnimation } from \"motion/react\";\nimport { MousePointerClick, Trash2 } from \"lucide-react\";\nimport { type ReactNode, useRef, useState } from \"react\";\nimport type { ButtonProps } from \"@/components/ui/button\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/registry/lib/utils\";\n\ninterface HoldButtonProps extends ButtonProps {\n  /** Milliseconds of sustained press required to commit. Default 1500. */\n  holdDuration?: number;\n  /** Fired exactly once when the hold completes without an early release. */\n  onHoldComplete?: () => void;\n  /** Signal-red register for destructive commitments. */\n  danger?: boolean;\n  /**\n   * Leading icon. Defaults to Trash2 on danger, MousePointerClick otherwise\n   * (the registry's press-affordance icon, per particle-button). Pass null\n   * to render no icon.\n   */\n  icon?: ReactNode | null;\n}\n\nexport default function HoldButton({\n  children,\n  className,\n  holdDuration = 1500,\n  onHoldComplete,\n  danger = false,\n  icon,\n  ...props\n}: HoldButtonProps) {\n  const [isHolding, setIsHolding] = useState(false);\n  const controls = useAnimation();\n  /* Incremented on every press and release; a completion only counts if the\n     token is unchanged after the fill finishes. */\n  const holdToken = useRef(0);\n\n  const accent = danger ? \"var(--red)\" : \"var(--color)\";\n\n  async function handleHoldStart() {\n    const token = ++holdToken.current;\n    setIsHolding(true);\n    controls.set({ width: \"0%\" });\n    await controls.start({\n      width: \"100%\",\n      transition: { duration: holdDuration / 1000, ease: \"linear\" },\n    });\n    if (holdToken.current === token) {\n      setIsHolding(false);\n      controls.set({ width: \"0%\" });\n      onHoldComplete?.();\n    }\n  }\n\n  function handleHoldEnd() {\n    if (!isHolding) return;\n    holdToken.current++;\n    setIsHolding(false);\n    controls.stop();\n    controls.start({ width: \"0%\", transition: { duration: 0.1 } });\n  }\n\n  return (\n    <Button\n      className={cn(\"relative min-w-40 touch-none overflow-hidden\", className)}\n      style={{\n        background: \"var(--backgroundColor)\",\n        color: accent,\n        border: `1px solid color-mix(in srgb, ${accent} 25%, transparent)`,\n      }}\n      onMouseDown={handleHoldStart}\n      onMouseLeave={handleHoldEnd}\n      onMouseUp={handleHoldEnd}\n      onTouchCancel={handleHoldEnd}\n      onTouchEnd={handleHoldEnd}\n      onTouchStart={handleHoldStart}\n      {...props}\n    >\n      <motion.div\n        aria-hidden=\"true\"\n        animate={controls}\n        className=\"pointer-events-none absolute top-0 left-0 h-full\"\n        style={{\n          background: `color-mix(in srgb, ${accent} 18%, transparent)`,\n        }}\n        initial={{ width: \"0%\" }}\n      />\n      <span className=\"relative flex w-full items-center justify-center gap-2\">\n        {icon === null\n          ? null\n          : (icon ??\n            (danger ? (\n              <Trash2 aria-hidden=\"true\" className=\"h-4 w-4\" />\n            ) : (\n              <MousePointerClick aria-hidden=\"true\" className=\"h-4 w-4\" />\n            )))}\n        {children ?? (isHolding ? \"Release to abort\" : \"Hold to confirm\")}\n      </span>\n    </Button>\n  );\n}\n"
    }
  ]
}
