{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-confirm",
  "type": "registry:ui",
  "title": "Hold to Confirm",
  "description": "A destructive-action button that fires only after a sustained press, with a rewinding progress ring.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/hold-to-confirm.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\n\ntype Phase = \"idle\" | \"holding\" | \"confirmed\";\n\nexport interface HoldToConfirmProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"button\">,\n    \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\" | \"onClick\"\n  > {\n  /** Label shown before confirmation (also the accessible name). */\n  children?: React.ReactNode;\n  /** Label shown (and announced) once confirmed. */\n  confirmedLabel?: string;\n  /** How long the press must be held, in ms. */\n  holdDuration?: number;\n  /** How long the confirmed state stays before resetting, in ms. */\n  resetDelay?: number;\n  /** Progress and confirmation colour. */\n  accent?: string;\n  /** Label / icon colour on top of the accent once confirmed. */\n  inkColor?: string;\n  /** Pill with label, or a round icon button with a caption. */\n  variant?: \"pill\" | \"circle\";\n  /** Instruction for assistive technology (linked via aria-describedby). */\n  hint?: string;\n  /** Icon in the centre of the ring. Defaults to a trash glyph. */\n  icon?: React.ReactNode;\n  /** Called once when the hold completes. */\n  onConfirm?: () => void;\n  /** Called when a hold starts. */\n  onHoldStart?: () => void;\n  /** Called when a hold is released before completing. */\n  onCancel?: () => void;\n}\n\nconst RING_R = 13;\n\nfunction TrashGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\" aria-hidden=\"true\">\n      <path d=\"M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/**\n * A destructive-action button that only fires after a sustained press. Progress is drawn\n * as an SVG ring; releasing early rewinds it. Works with pointer and Space/Enter.\n */\nexport const HoldToConfirm = React.forwardRef<HTMLButtonElement, HoldToConfirmProps>(function HoldToConfirm(\n  {\n    children = \"Hold to delete\",\n    confirmedLabel = \"Deleted\",\n    holdDuration = 1500,\n    resetDelay = 2000,\n    accent = \"#FF7448\",\n    inkColor = \"#0B0D0F\",\n    variant = \"pill\",\n    hint = \"Press and hold to confirm. Release to cancel.\",\n    icon,\n    onConfirm,\n    onHoldStart,\n    onCancel,\n    disabled,\n    className = \"\",\n    style,\n    onPointerDown,\n    onPointerUp,\n    onPointerCancel,\n    onKeyDown,\n    onKeyUp,\n    onBlur,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = useReducedMotion();\n  const hintId = React.useId();\n  const [phase, setPhase] = React.useState<Phase>(\"idle\");\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const progress = useMotionValue(0);\n  const fillWidth = useTransform(progress, (p) => `${p * 100}%`);\n  // Hide the round line cap when there is no progress, otherwise a dot remains visible.\n  const ringOpacity = useTransform(progress, (p) => (p > 0.001 ? 1 : 0));\n  const controls = React.useRef<AnimationPlaybackControls | null>(null);\n  const holding = React.useRef(false);\n  const resetTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  React.useEffect(\n    () => () => {\n      clearTimeout(resetTimer.current);\n      controls.current?.stop();\n    },\n    [],\n  );\n\n  const confirm = () => {\n    holding.current = false;\n    setPhase(\"confirmed\");\n    setAnnouncement(confirmedLabel);\n    onConfirm?.();\n    resetTimer.current = setTimeout(() => {\n      setPhase(\"idle\");\n      setAnnouncement(\"\");\n      controls.current = animate(progress, 0, { duration: reduceMotion ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] });\n    }, Math.max(resetDelay, 0));\n  };\n\n  const start = () => {\n    if (disabled || phase === \"confirmed\" || holding.current) return;\n    holding.current = true;\n    controls.current?.stop();\n    setPhase(\"holding\");\n    setAnnouncement(\"Holding…\");\n    onHoldStart?.();\n    // Resume from wherever a rewind left off, so a quick re-press is not penalised.\n    const remaining = (1 - progress.get()) * Math.max(holdDuration, 0);\n    controls.current = animate(progress, 1, { duration: remaining / 1000, ease: \"linear\", onComplete: confirm });\n  };\n\n  const cancel = () => {\n    if (!holding.current) return;\n    holding.current = false;\n    controls.current?.stop();\n    setPhase(\"idle\");\n    setAnnouncement(\"Released — not confirmed.\");\n    onCancel?.();\n    const p = progress.get();\n    controls.current = animate(progress, 0, {\n      duration: reduceMotion ? 0 : 0.2 + 0.35 * p,\n      ease: [0.22, 1, 0.36, 1],\n    });\n  };\n\n  const isActivationKey = (key: string) => key === \" \" || key === \"Enter\";\n  const confirmed = phase === \"confirmed\";\n  const pressed = phase === \"holding\" && !reduceMotion;\n\n  const ring = (size: string, strokeWidth: number) => (\n    <span aria-hidden=\"true\" className={`relative grid shrink-0 place-items-center ${size}`}>\n      <svg viewBox=\"0 0 32 32\" className=\"absolute inset-0 size-full -rotate-90\" fill=\"none\">\n        <circle cx=\"16\" cy=\"16\" r={RING_R} stroke=\"currentColor\" strokeOpacity={confirmed ? 0 : 0.16} strokeWidth={strokeWidth} />\n        <motion.circle\n          cx=\"16\"\n          cy=\"16\"\n          r={RING_R}\n          stroke={confirmed ? \"var(--hc-ink)\" : \"var(--hc-accent)\"}\n          strokeWidth={strokeWidth}\n          strokeLinecap=\"round\"\n          style={{ pathLength: progress, opacity: ringOpacity }}\n        />\n      </svg>\n      {confirmed ? (\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n          <motion.path\n            d=\"M3.5 8.5l3 3 6-7\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={{ pathLength: reduceMotion ? 1 : 0 }}\n            animate={{ pathLength: 1 }}\n            transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}\n          />\n        </svg>\n      ) : (\n        (icon ?? <TrashGlyph />)\n      )}\n    </span>\n  );\n\n  const surface = confirmed\n    ? \"border-transparent bg-[var(--hc-accent)] text-[var(--hc-ink)]\"\n    : \"border-zinc-900/15 bg-white/60 text-zinc-900 dark:border-white/15 dark:bg-white/[0.03] dark:text-zinc-100\";\n\n  const shape =\n    variant === \"circle\" ? \"size-16 justify-center rounded-full\" : \"h-12 gap-3 rounded-full pl-2 pr-5 text-sm font-medium tracking-tight\";\n\n  return (\n    <span className=\"inline-flex shrink-0 flex-col items-center gap-2.5\">\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        disabled={disabled}\n        aria-describedby={hintId}\n        aria-disabled={confirmed || undefined}\n        data-state={phase}\n        animate={{ scale: pressed ? 0.97 : 1 }}\n        transition={{ type: \"spring\", stiffness: 400, damping: 28 }}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n          if (event.button !== 0) return;\n          // Capture keeps the hold alive if the finger drifts slightly off the button.\n          event.currentTarget.setPointerCapture(event.pointerId);\n          start();\n        }}\n        onPointerUp={(event) => {\n          onPointerUp?.(event);\n          cancel();\n        }}\n        onPointerCancel={(event) => {\n          onPointerCancel?.(event);\n          cancel();\n        }}\n        onLostPointerCapture={cancel}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          if (!isActivationKey(event.key)) return;\n          event.preventDefault();\n          if (!event.repeat) start();\n        }}\n        onKeyUp={(event) => {\n          onKeyUp?.(event);\n          if (!isActivationKey(event.key)) return;\n          event.preventDefault();\n          cancel();\n        }}\n        onBlur={(event) => {\n          onBlur?.(event);\n          cancel();\n        }}\n        onContextMenu={(event) => event.preventDefault()}\n        className={`relative isolate inline-flex shrink-0 touch-none select-none items-center overflow-hidden whitespace-nowrap border transition-[background-color,color,border-color] duration-300 [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--hc-accent)] disabled:cursor-not-allowed disabled:opacity-50 ${shape} ${surface} ${className}`}\n        style={{ [\"--hc-accent\" as string]: accent, [\"--hc-ink\" as string]: inkColor, ...style }}\n        {...props}\n      >\n        {variant === \"pill\" && !confirmed && (\n          // Soft wash that tracks progress behind the label.\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"absolute inset-y-0 left-0 -z-10 bg-[color-mix(in_oklab,var(--hc-accent)_16%,transparent)]\"\n            style={{ width: fillWidth }}\n          />\n        )}\n        {variant === \"pill\" ? ring(\"size-8\", 2.25) : ring(\"size-full\", 1.75)}\n        {variant === \"pill\" ? (\n          <span className=\"relative\">{confirmed ? confirmedLabel : children}</span>\n        ) : (\n          <span className=\"sr-only\">{confirmed ? confirmedLabel : children}</span>\n        )}\n      </motion.button>\n\n      {variant === \"circle\" && (\n        <span aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {confirmed ? confirmedLabel : children}\n        </span>\n      )}\n\n      <span id={hintId} className=\"sr-only\">\n        {hint}\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </span>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/hold-to-confirm"
}
