{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-confirm",
  "title": "Hold to Confirm",
  "description": "A press-and-hold confirmation button for destructive actions with a filling progress ring, spring-back on early release, and a success check morph on completion",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/hold-to-confirm/hold-to-confirm.tsx",
      "content": "/**\n * Spectrum UI — HoldToConfirmButton\n *\n * A press-and-hold confirmation button for destructive actions. Holding the\n * button (pointer, Space or Enter) fills a circular progress ring around the\n * icon; releasing early springs the ring back and nothing fires. Holding to\n * completion fires onConfirm exactly once, draws a check in with a spring pop,\n * staggers the label swap behind it, tints the button emerald and then resets\n * to idle. Honors prefers-reduced-motion and announces confirmation to screen\n * readers.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <HoldToConfirmButton onConfirm={() => deleteProject(id)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"framer-motion\"\nimport { Trash2 } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface HoldToConfirmButtonProps {\n  /** Fires exactly once when the hold reaches completion */\n  onConfirm: () => void\n  /** How long the button must be held, in milliseconds. Default 1200 */\n  duration?: number\n  /** Idle label. Default \"Hold to delete\" */\n  label?: string\n  /** Label shown after a completed hold. Default \"Deleted\" */\n  confirmedLabel?: string\n  /** Replaces the default trash icon */\n  icon?: React.ReactNode\n  /** Visual size of the button. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Milliseconds before resetting to idle after confirming; 0 stays confirmed. Default 1500 */\n  resetDelay?: number\n  /** Disables pointer and keyboard interaction */\n  disabled?: boolean\n  className?: string\n}\n\n/** Input channels that can drive a hold; both may be active at once */\ntype HoldSource = \"pointer\" | \"keyboard\"\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst SIZES = {\n  sm: { button: \"h-8 gap-1.5 pl-2 pr-3 text-xs\", icon: 12, ring: 20, stroke: 2 },\n  md: { button: \"h-10 gap-2 pl-2.5 pr-4 text-sm\", icon: 14, ring: 24, stroke: 2 },\n  lg: { button: \"h-12 gap-2.5 pl-3 pr-5 text-base\", icon: 17, ring: 30, stroke: 2.5 },\n} as const\n\n/** Snappy micro spring — release spring-back, icon pops */\nconst SNAPPY_SPRING = { type: \"spring\", stiffness: 500, damping: 30 } as const\n/** Spring for the label roll between idle and confirmed */\nconst SWAP_SPRING = { type: \"spring\", stiffness: 400, damping: 30 } as const\n/** Check pop overshoots slightly — reserved for the positive confirmation */\nconst CHECK_POP_SPRING = { type: \"spring\", stiffness: 500, damping: 22 } as const\n/** Entrance/reveal ease */\nconst EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1]\n\n/** Scale while the button is held down */\nconst HOLD_SCALE = 0.97\n/** Seconds the press scale-down takes — eases in mechanically, no bounce */\nconst HOLD_SCALE_DURATION = 0.2\n/** Seconds the check waits after confirmation before drawing in */\nconst CHECK_DRAW_DELAY = 0.05\n/** Seconds the check stroke takes to draw */\nconst CHECK_DRAW_DURATION = 0.25\n/** Seconds the label swap trails the check draw on confirmation */\nconst LABEL_STAGGER = 0.12\n/** Seconds the ring takes to unwind when resetting to idle */\nconst RING_RESET_DURATION = 0.3\n\n/** Lucide check, drawn manually so the stroke can animate its pathLength */\nconst CHECK_PATH = \"M20 6 9 17l-5-5\"\n\nconst labelVariants = {\n  enter: (reduce: boolean) => (reduce ? { opacity: 0 } : { y: 6, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  // Exit carries its own transition so the confirmed label's entrance delay\n  // never leaks into the outgoing label when the button resets\n  exit: (reduce: boolean) =>\n    reduce\n      ? { opacity: 0, transition: { duration: 0 } }\n      : { y: -6, opacity: 0, transition: SWAP_SPRING },\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function HoldToConfirmButton({\n  onConfirm,\n  duration = 1200,\n  label = \"Hold to delete\",\n  confirmedLabel = \"Deleted\",\n  icon,\n  size = \"md\",\n  resetDelay = 1500,\n  disabled = false,\n  className,\n}: HoldToConfirmButtonProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [holding, setHolding] = useState(false)\n  const [confirmed, setConfirmed] = useState(false)\n\n  // 0 → 1 hold progress driving the ring's stroke-dashoffset\n  const progress = useMotionValue(0)\n  const animationRef = useRef<ReturnType<typeof animate> | null>(null)\n  const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const confirmedRef = useRef(false)\n  // Active input channels — a combined pointer+key hold must neither restart\n  // the fill nor cancel it while the other channel is still held\n  const holdSourcesRef = useRef<Set<HoldSource>>(new Set())\n\n  const { button: sizeClasses, icon: iconSize, ring, stroke } = SIZES[size]\n  const radius = (ring - stroke) / 2\n  const circumference = 2 * Math.PI * radius\n\n  const dashOffset = useTransform(progress, (p) => circumference * (1 - p))\n  // Ring (and its faint track) fades in as soon as a hold begins\n  const ringOpacity = useTransform(progress, [0, 0.04], [0, 1])\n\n  const holdSeconds = (duration / 1000).toFixed(1).replace(/\\.0$/, \"\")\n\n  const handleComplete = useCallback(() => {\n    if (confirmedRef.current) return\n    confirmedRef.current = true\n    // Physical holds outstanding at confirmation are consumed\n    holdSourcesRef.current.clear()\n    setHolding(false)\n    setConfirmed(true)\n    onConfirm()\n\n    if (resetDelay > 0) {\n      resetTimerRef.current = setTimeout(() => {\n        setConfirmed(false)\n        confirmedRef.current = false\n        animationRef.current?.stop()\n        animationRef.current = animate(progress, 0, {\n          duration: shouldReduceMotion ? 0.1 : RING_RESET_DURATION,\n          ease: \"easeOut\",\n        })\n      }, resetDelay)\n    }\n  }, [onConfirm, progress, resetDelay, shouldReduceMotion])\n\n  const startHold = useCallback(\n    (source: HoldSource) => {\n      if (disabled || confirmedRef.current) return\n      const sources = holdSourcesRef.current\n      const alreadyHolding = sources.size > 0\n      sources.add(source)\n      // A second input joining an active hold must not restart the fill\n      if (alreadyHolding) return\n      setHolding(true)\n      animationRef.current?.stop()\n      // Resume from wherever the ring is, keeping the fill rate constant —\n      // linear on purpose: the ring is a functional progress readout\n      animationRef.current = animate(progress, 1, {\n        duration: (duration * (1 - progress.get())) / 1000,\n        ease: \"linear\",\n        onComplete: handleComplete,\n      })\n    },\n    [disabled, duration, handleComplete, progress],\n  )\n\n  const cancelHold = useCallback(\n    (source?: HoldSource) => {\n      const sources = holdSourcesRef.current\n      if (source) sources.delete(source)\n      else sources.clear()\n      // Another input channel is still holding — keep filling\n      if (sources.size > 0) return\n      setHolding(false)\n      if (confirmedRef.current) return\n      animationRef.current?.stop()\n      animationRef.current = animate(\n        progress,\n        0,\n        shouldReduceMotion ? { duration: 0.15, ease: \"linear\" } : SNAPPY_SPRING,\n      )\n    },\n    [progress, shouldReduceMotion],\n  )\n\n  const handlePointerDown = useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      // Capture so slight finger drift doesn't cancel and pointerup is\n      // received even when released outside the button\n      try {\n        event.currentTarget.setPointerCapture(event.pointerId)\n      } catch {\n        // Pointer already inactive — nothing to capture\n      }\n      startHold(\"pointer\")\n    },\n    [startHold],\n  )\n\n  const cancelPointerHold = useCallback(() => cancelHold(\"pointer\"), [cancelHold])\n  const cancelAllHolds = useCallback(() => cancelHold(), [cancelHold])\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLButtonElement>) => {\n      if (event.key !== \" \" && event.key !== \"Enter\") return\n      // Keep the native button from firing click; ignore held-key repeats\n      event.preventDefault()\n      if (event.repeat) return\n      startHold(\"keyboard\")\n    },\n    [startHold],\n  )\n\n  const handleKeyUp = useCallback(\n    (event: React.KeyboardEvent<HTMLButtonElement>) => {\n      if (event.key !== \" \" && event.key !== \"Enter\") return\n      event.preventDefault()\n      cancelHold(\"keyboard\")\n    },\n    [cancelHold],\n  )\n\n  useEffect(() => {\n    return () => {\n      animationRef.current?.stop()\n      if (resetTimerRef.current) clearTimeout(resetTimerRef.current)\n    }\n  }, [])\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      aria-label={`${label}. Press and hold for ${holdSeconds} seconds to confirm`}\n      onPointerDown={handlePointerDown}\n      onPointerUp={cancelPointerHold}\n      onPointerLeave={cancelPointerHold}\n      onPointerCancel={cancelPointerHold}\n      onKeyDown={handleKeyDown}\n      onKeyUp={handleKeyUp}\n      onBlur={cancelAllHolds}\n      onContextMenu={(event) => event.preventDefault()}\n      animate={{ scale: holding && !shouldReduceMotion ? HOLD_SCALE : 1 }}\n      transition={\n        shouldReduceMotion\n          ? { duration: 0 }\n          : holding\n            ? // Pressing in eases down mechanically…\n              { duration: HOLD_SCALE_DURATION, ease: EASE_OUT }\n            : // …releasing springs back snappily\n              SNAPPY_SPRING\n      }\n      className={cn(\n        \"relative inline-flex touch-none select-none items-center justify-center rounded-full border font-medium transition-colors\",\n        \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        confirmed\n          ? \"border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-400\"\n          : \"border-rose-200 bg-white text-rose-600 hover:bg-rose-50 dark:border-rose-500/30 dark:bg-neutral-900 dark:text-rose-400 dark:hover:bg-rose-500/10\",\n        sizeClasses,\n        className,\n      )}\n    >\n      {/* Icon + progress ring */}\n      <span\n        className=\"relative inline-flex shrink-0 items-center justify-center\"\n        style={{ width: ring, height: ring }}\n        aria-hidden=\"true\"\n      >\n        <motion.span\n          className=\"inline-flex items-center justify-center\"\n          initial={false}\n          animate={{\n            scale: confirmed ? 0 : 1,\n            opacity: confirmed ? 0 : 1,\n          }}\n          transition={shouldReduceMotion ? { duration: 0 } : SNAPPY_SPRING}\n        >\n          {icon ?? <Trash2 size={iconSize} strokeWidth={2} />}\n        </motion.span>\n        {/* Success check — pops with a slight overshoot while its stroke draws */}\n        <motion.span\n          className=\"absolute inset-0 flex items-center justify-center\"\n          initial={false}\n          animate={{\n            scale: confirmed ? 1 : 0,\n            opacity: confirmed ? 1 : 0,\n          }}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : {\n                  ...CHECK_POP_SPRING,\n                  delay: confirmed ? CHECK_DRAW_DELAY : 0,\n                }\n          }\n        >\n          <svg\n            viewBox=\"0 0 24 24\"\n            width={iconSize}\n            height={iconSize}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <motion.path\n              d={CHECK_PATH}\n              initial={false}\n              animate={{ pathLength: confirmed ? 1 : 0 }}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : confirmed\n                    ? {\n                        duration: CHECK_DRAW_DURATION,\n                        ease: EASE_OUT,\n                        delay: CHECK_DRAW_DELAY,\n                      }\n                    : { duration: 0.1 }\n              }\n            />\n          </svg>\n        </motion.span>\n\n        <motion.svg\n          viewBox={`0 0 ${ring} ${ring}`}\n          width={ring}\n          height={ring}\n          className=\"absolute inset-0 -rotate-90\"\n          style={{ opacity: ringOpacity }}\n        >\n          <circle\n            cx={ring / 2}\n            cy={ring / 2}\n            r={radius}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeOpacity={0.2}\n            strokeWidth={stroke}\n          />\n          <motion.circle\n            cx={ring / 2}\n            cy={ring / 2}\n            r={radius}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={stroke}\n            strokeLinecap=\"round\"\n            strokeDasharray={circumference}\n            style={{ strokeDashoffset: dashOffset }}\n          />\n        </motion.svg>\n      </span>\n\n      {/* Label */}\n      <span\n        className={cn(\n          \"relative inline-flex overflow-hidden transition-opacity duration-200\",\n          holding && \"opacity-60\",\n        )}\n      >\n        <AnimatePresence\n          mode=\"popLayout\"\n          initial={false}\n          custom={shouldReduceMotion ?? false}\n        >\n          <motion.span\n            key={confirmed ? \"confirmed\" : \"idle\"}\n            className=\"inline-block whitespace-nowrap\"\n            custom={shouldReduceMotion ?? false}\n            variants={labelVariants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    ...SWAP_SPRING,\n                    // Label swap trails the check draw on confirmation\n                    delay: confirmed ? CHECK_DRAW_DELAY + LABEL_STAGGER : 0,\n                  }\n            }\n          >\n            {confirmed ? confirmedLabel : label}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n\n      {/* Screen reader confirmation announcement */}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {confirmed ? confirmedLabel : \"\"}\n      </span>\n    </motion.button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/hold-to-confirm.tsx"
    }
  ],
  "type": "registry:component"
}
