{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-button",
  "type": "registry:ui",
  "title": "HoldButton",
  "description": "A hold-to-confirm button whose inner fill grows from 0 to 100%; release early to shake and abort, hold to the end to fire the action.",
  "files": [
    {
      "path": "components/evil-buttons/hold-button.tsx",
      "type": "registry:ui",
      "target": "components/evil-buttons/hold-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype HoldState = \"idle\" | \"holding\" | \"success\";\n\nexport interface HoldButtonProps\n  extends Omit<\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    \"onClick\" | \"onAbort\"\n  > {\n  /** Time in milliseconds the user must hold before the action fires. */\n  duration?: number;\n  /** Label shown while idle. */\n  label?: React.ReactNode;\n  /** Label shown while the button is being held. */\n  holdingLabel?: React.ReactNode;\n  /** Label shown once the action completes. */\n  successLabel?: React.ReactNode;\n  /** Fired when the hold reaches 100%. */\n  onConfirm?: () => void;\n  /** Fired when the hold is released early. Receives the progress (0-1) reached. */\n  onAbort?: (progress: number) => void;\n  /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\n  resetAfter?: number;\n}\n\nconst CheckIcon = () => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2.25\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    className=\"size-3.5 shrink-0\"\n    aria-hidden\n  >\n    <path d=\"M20 6 9 17l-5-5\" />\n  </svg>\n);\n\nexport const HoldButton = React.forwardRef<HTMLButtonElement, HoldButtonProps>(\n  (\n    {\n      duration = 1500,\n      label = \"Hold to delete\",\n      holdingLabel = \"Keep holding…\",\n      successLabel = \"Deleted\",\n      onConfirm,\n      onAbort,\n      resetAfter = 1400,\n      className,\n      disabled,\n      type = \"button\",\n      ...props\n    },\n    ref,\n  ) => {\n    const [state, setState] = React.useState<HoldState>(\"idle\");\n    const [width, setWidth] = React.useState(0);\n\n    const buttonRef = React.useRef<HTMLButtonElement | null>(null);\n    const fillRef = React.useRef<HTMLSpanElement | null>(null);\n    const rafRef = React.useRef<number | null>(null);\n    const startRef = React.useRef(0);\n    const progressRef = React.useRef(0);\n    const resetTimeoutRef = React.useRef<number | null>(null);\n\n    const setButtonRef = (node: HTMLButtonElement | null) => {\n      buttonRef.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    };\n\n    // Keep the knockout label sized to the full button width so the reveal\n    // wipes cleanly left-to-right without distorting the text.\n    React.useEffect(() => {\n      const node = buttonRef.current;\n      if (!node || typeof ResizeObserver === \"undefined\") return;\n      const observer = new ResizeObserver(([entry]) => {\n        setWidth(entry.contentRect.width + 32);\n      });\n      observer.observe(node);\n      return () => observer.disconnect();\n    }, []);\n\n    React.useEffect(() => {\n      return () => {\n        if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n        if (resetTimeoutRef.current !== null) {\n          window.clearTimeout(resetTimeoutRef.current);\n        }\n      };\n    }, []);\n\n    const stopLoop = () => {\n      if (rafRef.current !== null) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n    };\n\n    const setFill = (value: number, animated: boolean) => {\n      progressRef.current = value;\n      const node = fillRef.current;\n      if (!node) return;\n      node.style.transition = animated ? \"width 260ms ease-out\" : \"none\";\n      node.style.width = `${value * 100}%`;\n    };\n\n    const complete = () => {\n      stopLoop();\n      setFill(1, false);\n      setState(\"success\");\n      onConfirm?.();\n      if (resetAfter > 0) {\n        resetTimeoutRef.current = window.setTimeout(() => {\n          setFill(0, true);\n          setState(\"idle\");\n        }, resetAfter);\n      }\n    };\n\n    const tick = (now: number) => {\n      const value = Math.min((now - startRef.current) / duration, 1);\n      setFill(value, false);\n      if (value >= 1) {\n        complete();\n        return;\n      }\n      rafRef.current = requestAnimationFrame(tick);\n    };\n\n    const startHold = () => {\n      if (disabled || state !== \"idle\") return;\n      if (resetTimeoutRef.current !== null) {\n        window.clearTimeout(resetTimeoutRef.current);\n      }\n      setState(\"holding\");\n      startRef.current = performance.now();\n      setFill(0, false);\n      stopLoop();\n      rafRef.current = requestAnimationFrame(tick);\n    };\n\n    const cancelHold = () => {\n      if (state !== \"holding\") return;\n      stopLoop();\n      const reached = progressRef.current;\n      setState(\"idle\");\n      setFill(0, true);\n      buttonRef.current?.animate?.(\n        [\n          { transform: \"translateX(0)\" },\n          { transform: \"translateX(-4px)\" },\n          { transform: \"translateX(4px)\" },\n          { transform: \"translateX(-2px)\" },\n          { transform: \"translateX(2px)\" },\n          { transform: \"translateX(0)\" },\n        ],\n        { duration: 300, easing: \"ease-in-out\" },\n      );\n      onAbort?.(reached);\n    };\n\n    const isSuccess = state === \"success\";\n    const isHolding = state === \"holding\";\n\n    const content = (\n      <>\n        {isSuccess ? <CheckIcon /> : null}\n        <span>\n          {isSuccess ? successLabel : isHolding ? holdingLabel : label}\n        </span>\n      </>\n    );\n\n    return (\n      <button\n        ref={setButtonRef}\n        type={type}\n        disabled={disabled}\n        aria-live=\"polite\"\n        data-state={state}\n        onPointerDown={(e) => {\n          if (e.button !== 0 && e.pointerType === \"mouse\") return;\n          e.currentTarget.setPointerCapture?.(e.pointerId);\n          startHold();\n        }}\n        onPointerUp={cancelHold}\n        onPointerCancel={cancelHold}\n        onLostPointerCapture={cancelHold}\n        onKeyDown={(e) => {\n          if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n            e.preventDefault();\n            startHold();\n          }\n        }}\n        onKeyUp={(e) => {\n          if (e.key === \" \" || e.key === \"Enter\") {\n            e.preventDefault();\n            cancelHold();\n          }\n        }}\n        onContextMenu={(e) => e.preventDefault()}\n        className={cn(\n          \"relative inline-flex h-9 min-w-44 cursor-pointer items-center justify-center overflow-hidden rounded-[10px] px-4 text-sm font-medium text-neutral-50 outline-none select-none\",\n          // Graded dark surface matching MinimalButton: dark outer hairline, faint inner ring, top highlight, soft drop.\n          \"bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)]\",\n          \"transition-[scale,filter,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] hover:brightness-110\",\n          \"active:scale-[0.97] active:brightness-95 active:duration-100 active:ease-out active:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)] motion-reduce:active:scale-100\",\n          \"touch-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n        {...props}\n      >\n        {/* Base label, visible where the fill has not yet covered. */}\n        <span className=\"relative z-10 inline-flex items-center gap-1.5\">\n          {content}\n        </span>\n\n        {/* Fill overlay that wipes across and reveals a knockout inverted label. */}\n        <span\n          ref={fillRef}\n          aria-hidden\n          className=\"absolute inset-y-0 left-0 z-20 w-0 overflow-hidden bg-linear-to-b from-neutral-50 to-neutral-200\"\n        >\n          <span\n            className=\"absolute inset-y-0 left-0 inline-flex items-center justify-center gap-1.5 text-neutral-900\"\n            style={{ width: width ? `${width}px` : \"100%\" }}\n          >\n            {content}\n          </span>\n        </span>\n      </button>\n    );\n  },\n);\n\nHoldButton.displayName = \"HoldButton\";\n\nexport default HoldButton;\n"
    }
  ],
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ]
}
