{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-delete-button",
  "type": "registry:ui",
  "title": "Hold To Delete Button",
  "description": "A hold-to-confirm destructive button with pointer capture, keyboard activation, cancellation, and reduced-motion support.",
  "files": [
    {
      "path": "registry/sonaui/hold-to-delete-button/hold-to-delete-button.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Check, Trash2 } from \"lucide-react\";\nimport {\n  animate,\n  motion,\n  useAnimationControls,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface HoldToDeleteButtonProps {\n  /** Text displayed inside the button. */\n  label?: string;\n  /**\n   * Duration in milliseconds the user must hold before the action triggers.\n   * @default 2000\n   */\n  holdDuration?: number;\n  /**\n   * Duration in milliseconds the success state is visible before auto-resetting.\n   * @default 1200\n   */\n  successDuration?: number;\n  /** Called once when the hold completes. */\n  onDelete?: () => void;\n  /** Whether the button ignores interaction. @default false */\n  disabled?: boolean;\n  /** Additional CSS classes for the button. */\n  className?: string;\n}\n\nexport default function HoldToDeleteButton({\n  label = \"Hold To Delete\",\n  holdDuration = 2000,\n  successDuration = 1200,\n  onDelete,\n  disabled = false,\n  className,\n}: HoldToDeleteButtonProps) {\n  const [isHolding, setIsHolding] = useState(false);\n  const [isCompleted, setIsCompleted] = useState(false);\n  const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const startedAtRef = useRef(0);\n  const buttonControls = useAnimationControls();\n  const progress = useMotionValue(0);\n  const shouldReduceMotion = useReducedMotion();\n  const resolvedHoldDuration = Math.max(0, holdDuration);\n  const resolvedSuccessDuration = Math.max(0, successDuration);\n  const progressClipPath = useTransform(\n    progress,\n    (value) => `inset(0 ${100 - value * 100}% 0 0)`,\n  );\n\n  const clearHoldTimer = () => {\n    if (holdTimerRef.current) clearTimeout(holdTimerRef.current);\n    holdTimerRef.current = null;\n  };\n\n  const cancelHold = () => {\n    if (!holdTimerRef.current) return;\n    const heldRatio =\n      resolvedHoldDuration === 0\n        ? 1\n        : Math.min(\n            (performance.now() - startedAtRef.current) / resolvedHoldDuration,\n            1,\n          );\n\n    clearHoldTimer();\n    setIsHolding(false);\n    animate(progress, 0, {\n      type: \"spring\",\n      duration: 0.3,\n      bounce: 0,\n    });\n\n    if (shouldReduceMotion || heldRatio < 0.15) {\n      buttonControls.start({ transform: \"translateX(0) scale(1)\" });\n      return;\n    }\n\n    const isPastHalfway = heldRatio >= 0.5;\n    buttonControls.start(\n      {\n        transform: isPastHalfway\n          ? [\n              \"translateX(0) rotate(0deg) scale(1)\",\n              \"translateX(-7px) rotate(-1.2deg) scale(0.985)\",\n              \"translateX(6px) rotate(1deg) scale(0.99)\",\n              \"translateX(-4px) rotate(-0.6deg) scale(0.995)\",\n              \"translateX(2px) rotate(0.3deg) scale(1)\",\n              \"translateX(0) rotate(0deg) scale(1)\",\n            ]\n          : [\n              \"translateX(0) scale(1)\",\n              \"translateX(-3px) scale(0.99)\",\n              \"translateX(3px) scale(0.995)\",\n              \"translateX(0) scale(1)\",\n            ],\n      },\n      {\n        duration: isPastHalfway ? 0.38 : 0.24,\n        ease: [0.23, 1, 0.32, 1],\n      },\n    );\n  };\n\n  const resetState = () => {\n    clearHoldTimer();\n    if (successTimerRef.current) clearTimeout(successTimerRef.current);\n    successTimerRef.current = null;\n    setIsCompleted(false);\n    buttonControls.start({ transform: \"translateX(0) scale(1)\" });\n    animate(progress, 0, {\n      type: \"spring\",\n      duration: 0.3,\n      bounce: 0,\n    });\n  };\n\n  const handlePointerDown = () => {\n    if (isCompleted || disabled) return;\n    clearHoldTimer();\n    startedAtRef.current = performance.now();\n    setIsHolding(true);\n    buttonControls.start({\n      transform: shouldReduceMotion\n        ? \"translateX(0) scale(1)\"\n        : \"translateX(0) scale(0.97)\",\n      transition: { duration: 0.12, ease: [0.23, 1, 0.32, 1] },\n    });\n    animate(progress, 1, {\n      duration: resolvedHoldDuration / 1000,\n      ease: \"linear\",\n    });\n    holdTimerRef.current = setTimeout(() => {\n      holdTimerRef.current = null;\n      setIsHolding(false);\n      setIsCompleted(true);\n      buttonControls.start({\n        transform: \"translateX(0) scale(1)\",\n        transition: { duration: 0.16, ease: [0.23, 1, 0.32, 1] },\n      });\n      progress.set(1);\n      onDelete?.();\n    }, resolvedHoldDuration);\n  };\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: successDuration is stable per render\n  useEffect(() => {\n    if (!isCompleted) return;\n    successTimerRef.current = setTimeout(resetState, resolvedSuccessDuration);\n    return () => {\n      if (successTimerRef.current) clearTimeout(successTimerRef.current);\n    };\n  }, [isCompleted, resolvedSuccessDuration]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: unmount-only cleanup\n  useEffect(\n    () => () => {\n      clearHoldTimer();\n      if (successTimerRef.current) clearTimeout(successTimerRef.current);\n    },\n    [],\n  );\n\n  const renderVisualContent = () => (\n    <>\n      <span className=\"relative grid size-4 shrink-0 place-items-center\">\n        {isCompleted ? (\n          <Check aria-hidden=\"true\" className=\"size-4\" strokeWidth={2.25} />\n        ) : (\n          <Trash2 aria-hidden=\"true\" className=\"size-4\" strokeWidth={2} />\n        )}\n      </span>\n      <span className=\"relative grid text-sm leading-none [&>*]:col-start-1 [&>*]:row-start-1\">\n        <span\n          className={cn(isHolding || isCompleted ? \"opacity-0\" : \"opacity-100\")}\n        >\n          {label}\n        </span>\n        <span\n          className={cn(\n            isHolding && !isCompleted ? \"opacity-100\" : \"opacity-0\",\n          )}\n        >\n          Keep holding\n        </span>\n        <span className={cn(isCompleted ? \"opacity-100\" : \"opacity-0\")}>\n          Deleted\n        </span>\n      </span>\n    </>\n  );\n\n  return (\n    <motion.button\n      type=\"button\"\n      className={cn(\n        \"relative flex h-12 min-w-48 touch-none cursor-pointer select-none items-center justify-center gap-2 overflow-clip rounded-full bg-destructive/10 px-5 font-medium text-destructive shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-destructive)_40%,transparent),0_1px_2px_rgb(0_0_0/0.06)] outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-destructive/50 disabled:cursor-not-allowed disabled:opacity-50\",\n        isCompleted &&\n          \"bg-green-600/10 text-green-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-green-600)_40%,transparent),0_1px_2px_rgb(0_0_0/0.06)] focus-visible:ring-green-600/50\",\n        className,\n      )}\n      disabled={disabled}\n      aria-busy={isHolding}\n      animate={buttonControls}\n      onPointerDown={(event) => {\n        if (disabled) return;\n        event.currentTarget.setPointerCapture(event.pointerId);\n        handlePointerDown();\n      }}\n      onPointerUp={cancelHold}\n      onPointerLeave={cancelHold}\n      onPointerCancel={cancelHold}\n      onKeyDown={(e) => {\n        if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n          e.preventDefault();\n          handlePointerDown();\n        }\n      }}\n      onKeyUp={(e) => {\n        if (e.key === \" \" || e.key === \"Enter\") cancelHold();\n      }}\n      onBlur={cancelHold}\n    >\n      <span className=\"relative flex items-center justify-center gap-2\">\n        {renderVisualContent()}\n      </span>\n      <motion.span\n        aria-hidden=\"true\"\n        className={cn(\n          \"absolute inset-0 flex items-center justify-center gap-2 bg-destructive text-white\",\n          isCompleted && \"bg-green-600\",\n        )}\n        style={{ clipPath: progressClipPath }}\n      >\n        {renderVisualContent()}\n      </motion.span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {isCompleted ? \"Deleted\" : isHolding ? \"Keep holding\" : label}\n      </span>\n    </motion.button>\n  );\n}\n",
      "target": "components/ui/hold-to-delete-button/hold-to-delete-button.tsx"
    }
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}