{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-button",
  "type": "registry:ui",
  "title": "Hold Button",
  "description": "A button that fires only after a deliberate press and hold, with a fill sweeping across to count out the wait.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/hold-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface HoldButtonProps extends React.ComponentProps<\"button\"> {\n  /** Milliseconds the button has to be held down before it fires. */\n  duration?: number\n  /** Called once, when a hold runs the whole way through. */\n  onHold?: () => void\n  /** Fill that sweeps across the button. Keep it translucent so the label survives. */\n  color?: string\n}\n\n/** How long the fill takes to run back out when a hold is abandoned. */\nconst RETURN_MS = 260\n/** How long a completed fill sits at full before it clears. */\nconst SETTLE_MS = 200\n/** How long the completed fill takes to fade off. */\nconst CLEAR_MS = 240\n\n/** Fast at first, then easing to a stop. The fill draining should feel let go of. */\nfunction easeOutCubic(t: number) {\n  return 1 - Math.pow(1 - t, 3)\n}\n\nexport function HoldButton({\n  children,\n  className,\n  duration = 1200,\n  onHold,\n  color = \"color-mix(in oklch, var(--primary, currentColor) 22%, transparent)\",\n  disabled,\n  style,\n  ref: forwardedRef,\n  ...props\n}: HoldButtonProps) {\n  const ref = React.useRef<HTMLButtonElement>(null)\n\n  // The button needs its own ref to write the fill to, so a caller's ref is\n  // pointed at the same node rather than replacing it.\n  React.useImperativeHandle(\n    forwardedRef,\n    () => ref.current as HTMLButtonElement\n  )\n  const frame = React.useRef(0)\n  const settle = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n  const [holding, setHolding] = React.useState(false)\n  const [clearing, setClearing] = React.useState(false)\n  // A completed button stays pressed until its fill has cleared. Springing\n  // back to full size the instant the action lands reads as a bounce, and\n  // fights whatever the caller is doing with the button at the same moment.\n  const [settling, setSettling] = React.useState(false)\n  const spent = React.useRef(false)\n\n  // Progress is a ref written straight to a custom property. A fill that\n  // re-rendered React sixty times a second to cross the button would be absurd.\n  const state = React.useRef({\n    progress: 0,\n    filling: false,\n    last: 0,\n    from: 0,\n    since: 0,\n  })\n\n  const write = React.useCallback(() => {\n    ref.current?.style.setProperty(\n      \"--hold-progress\",\n      `${state.current.progress}`\n    )\n  }, [])\n\n  const tick = React.useCallback(\n    (now: number) => {\n      const current = state.current\n\n      if (current.filling) {\n        // Filling is linear, because it is a promise about how much longer.\n        const elapsed = current.last ? Math.min(now - current.last, 64) : 16\n        current.last = now\n        current.progress = Math.min(1, current.progress + elapsed / duration)\n        write()\n\n        if (current.progress >= 1) {\n          current.filling = false\n          spent.current = true\n          setHolding(false)\n          setSettling(true)\n          onHold?.()\n\n          // A completed fill does not run backwards. Rewinding it would read\n          // as an undo of the thing that just happened. It sits at full, fades\n          // off, and is reset to zero behind the fade.\n          settle.current = setTimeout(() => {\n            setClearing(true)\n            settle.current = setTimeout(() => {\n              current.progress = 0\n              write()\n              setClearing(false)\n              setSettling(false)\n            }, CLEAR_MS)\n          }, SETTLE_MS)\n          return\n        }\n\n        frame.current = requestAnimationFrame(tick)\n        return\n      }\n\n      // Draining is eased and on its own clock, so it takes the same quick\n      // moment whether it fell from full or from a tenth of the way across.\n      const t = Math.min(1, (now - current.since) / RETURN_MS)\n      current.progress = current.from * (1 - easeOutCubic(t))\n      write()\n\n      if (t < 1) {\n        frame.current = requestAnimationFrame(tick)\n      }\n    },\n    [duration, onHold, write]\n  )\n\n  const drain = React.useCallback(() => {\n    const current = state.current\n    current.filling = false\n    current.from = current.progress\n    current.since = performance.now()\n    current.last = 0\n\n    cancelAnimationFrame(frame.current)\n    frame.current = requestAnimationFrame(tick)\n  }, [tick])\n\n  const press = React.useCallback(() => {\n    if (disabled) {\n      return\n    }\n\n    clearTimeout(settle.current)\n    cancelAnimationFrame(frame.current)\n\n    // Pressing again during the clear starts from empty rather than picking up\n    // the fill that already did its job.\n    if (spent.current) {\n      spent.current = false\n      state.current.progress = 0\n      write()\n      setClearing(false)\n      setSettling(false)\n    }\n\n    state.current.filling = true\n    state.current.last = 0\n    setHolding(true)\n    frame.current = requestAnimationFrame(tick)\n  }, [disabled, tick, write])\n\n  const release = React.useCallback(() => {\n    if (!state.current.filling) {\n      return\n    }\n\n    setHolding(false)\n    drain()\n  }, [drain])\n\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current)\n      clearTimeout(settle.current)\n    },\n    []\n  )\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key !== \" \" && event.key !== \"Enter\") {\n      return\n    }\n\n    // Space scrolls and Enter fires a click. Neither should stand in for the\n    // hold this button exists to require.\n    event.preventDefault()\n    if (!event.repeat) {\n      press()\n    }\n  }\n\n  const handleKeyUp = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \" \" || event.key === \"Enter\") {\n      event.preventDefault()\n      release()\n    }\n  }\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      data-slot=\"hold-button\"\n      data-holding={holding || settling ? \"\" : undefined}\n      disabled={disabled}\n      onPointerDown={press}\n      onPointerUp={release}\n      onPointerLeave={release}\n      onPointerCancel={release}\n      onKeyDown={handleKeyDown}\n      onKeyUp={handleKeyUp}\n      onBlur={release}\n      className={cn(\n        \"relative isolate inline-flex items-center justify-center rounded-lg border select-none\",\n        \"transition-transform duration-150 ease-[var(--ease-out-quart)] data-holding:scale-[0.98]\",\n        \"motion-reduce:transition-none\",\n        !disabled && \"cursor-pointer\",\n        className\n      )}\n      style={style}\n      {...props}\n    >\n      {/* The fill is the whole affordance: it is the only thing telling you how\n          much longer to keep holding. It is a scale, not a width, so filling\n          the button costs no layout, and it is clipped by a wrapper rather\n          than rounded itself, or the scale would squash its own corners. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\"\n      >\n        <span\n          className=\"absolute inset-0 origin-left transition-opacity ease-[var(--ease-out-quart)] motion-reduce:transition-none\"\n          style={{\n            background: color,\n            transform: \"scaleX(var(--hold-progress, 0))\",\n            opacity: clearing ? 0 : 1,\n            transitionDuration: `${CLEAR_MS}ms`,\n          }}\n        />\n      </span>\n\n      <span className=\"relative\">{children}</span>\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "ease-out-quart": "cubic-bezier(0.165, 0.84, 0.44, 1)"
    }
  }
}