{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-confirm",
  "type": "registry:ui",
  "title": "Hold to Confirm",
  "description": "Press and hold to commit; the label flips color under the filling edge.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/hold-to-confirm.tsx",
      "content": "\"use client\";\nimport { Button } from \"@base-ui/react/button\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype HoldState = \"idle\" | \"holding\" | \"hint\" | \"armed\" | \"done\";\n\nexport type HoldToConfirmProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onClick\"> & {\n  /** The action, as a verb: \"Delete project\". Also the accessible name. */\n  children: React.ReactNode;\n  /** Called once the hold completes. */\n  onConfirm: () => void;\n  /** Milliseconds the press must be held. */\n  duration?: number;\n  /** Shown after a tap that was too short, so people learn the gesture. */\n  hint?: string;\n  /** Shown when a screen reader or switch sends a plain click: a second click confirms. */\n  armedLabel?: string;\n  confirmedLabel?: string;\n  icon?: React.ReactNode;\n  tone?: \"danger\" | \"neutral\";\n  size?: \"sm\" | \"md\";\n  /** Milliseconds before returning to rest after confirming. `null` keeps the confirmed state. */\n  resetAfter?: number | null;\n};\n\nexport function HoldToConfirm({\n  children,\n  onConfirm,\n  duration = 1200,\n  hint = \"Hold to confirm\",\n  armedLabel = \"Press again to confirm\",\n  confirmedLabel = \"Done\",\n  icon,\n  tone = \"danger\",\n  size = \"md\",\n  resetAfter = 2000,\n  disabled,\n  className,\n  ref,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onPointerCancel,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  ...rest\n}: HoldToConfirmProps) {\n  const [state, setState] = useState<HoldState>(\"idle\");\n  const reduce = useReducedMotion();\n  const hintId = useId();\n  const progress = useMotionValue(0);\n  // The fill is a second copy of the face, clipped from the right, so the label flips color exactly under the edge.\n  const clip = useTransform(progress, (p) => `inset(0 ${(1 - p) * 100}% 0 0)`);\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  const startedAt = useRef(0);\n  const holdingRef = useRef(false);\n  const timer = useRef<number>(undefined);\n\n  useEffect(\n    () => () => {\n      anim.current?.stop();\n      window.clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const later = (fn: () => void, ms: number) => {\n    window.clearTimeout(timer.current);\n    timer.current = window.setTimeout(fn, ms);\n  };\n\n  const confirm = () => {\n    holdingRef.current = false;\n    anim.current?.stop();\n    progress.set(1);\n    setState(\"done\");\n    onConfirm();\n    if (resetAfter != null)\n      later(() => {\n        setState(\"idle\");\n        anim.current = animate(progress, 0, { duration: 0.4, ease: ease.inOut });\n      }, resetAfter);\n  };\n\n  const start = () => {\n    if (disabled || holdingRef.current || state === \"done\") return;\n    holdingRef.current = true;\n    window.clearTimeout(timer.current);\n    startedAt.current = performance.now();\n    setState(\"holding\");\n    anim.current?.stop();\n    // Continues from wherever a drain left it, so a quick re-press picks up the progress.\n    anim.current = animate(progress, 1, {\n      duration: ((1 - progress.get()) * duration) / 1000,\n      ease: \"linear\",\n      onComplete: confirm,\n    });\n  };\n\n  const release = () => {\n    if (!holdingRef.current) return;\n    holdingRef.current = false;\n    anim.current?.stop();\n    const tapped = performance.now() - startedAt.current < 250;\n    // Drains faster than it fills, from wherever it is.\n    anim.current = animate(progress, 0, { duration: Math.max(0.18, progress.get() * 0.5), ease: ease.out });\n    if (tapped) {\n      setState(\"hint\");\n      later(() => setState(\"idle\"), 1800);\n    } else setState(\"idle\");\n  };\n\n  const isKey = (k: string) => k === \" \" || k === \"Enter\";\n\n  const shown: Exclude<HoldState, \"holding\"> = state === \"holding\" ? \"idle\" : state;\n  function label(k: Exclude<HoldState, \"holding\">) {\n    return k === \"hint\" ? hint : k === \"armed\" ? armedLabel : k === \"done\" ? confirmedLabel : children;\n  }\n\n  const face = (filled: boolean) => (\n    <span className=\"grid place-items-center\">\n      {([\"idle\", \"hint\", \"armed\", \"done\"] as const).map((k) => (\n        <Row key={k} icon={k === \"done\" || (k === \"idle\" && icon) ? <span /> : null} size={size} className=\"invisible col-start-1 row-start-1\">\n          {label(k)}\n        </Row>\n      ))}\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={shown}\n          className=\"col-start-1 row-start-1 flex\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n          transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n        >\n          <Row icon={shown === \"done\" ? <Tick reduce={!!reduce} animated={filled} /> : shown === \"idle\" ? icon : null} size={size}>\n            {label(shown)}\n          </Row>\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n\n  const danger = tone === \"danger\";\n\n  return (\n    <>\n      <Button\n        ref={ref as React.Ref<HTMLElement>}\n        type=\"button\"\n        data-state={state}\n        data-tone={tone}\n        data-size={size}\n        disabled={disabled}\n        aria-describedby={hintId}\n        onPointerDown={(e: React.PointerEvent<HTMLButtonElement>) => {\n          onPointerDown?.(e);\n          if (e.button === 0 && !e.defaultPrevented) start();\n        }}\n        onPointerUp={(e: React.PointerEvent<HTMLButtonElement>) => {\n          onPointerUp?.(e);\n          release();\n        }}\n        // Dragging off the button is how people change their mind.\n        onPointerLeave={(e: React.PointerEvent<HTMLButtonElement>) => {\n          onPointerLeave?.(e);\n          release();\n        }}\n        onPointerCancel={(e: React.PointerEvent<HTMLButtonElement>) => {\n          onPointerCancel?.(e);\n          release();\n        }}\n        onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {\n          onKeyDown?.(e);\n          if (!isKey(e.key)) return;\n          // Hold is the only way in by keyboard; swallow the native click.\n          e.preventDefault();\n          if (!e.repeat) start();\n        }}\n        onKeyUp={(e: React.KeyboardEvent<HTMLButtonElement>) => {\n          onKeyUp?.(e);\n          if (!isKey(e.key)) return;\n          e.preventDefault();\n          release();\n        }}\n        onBlur={(e: React.FocusEvent<HTMLButtonElement>) => {\n          onBlur?.(e);\n          release();\n        }}\n        onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n          // Pointer and key presses are handled above. A click with no pointer\n          // behind it comes from a screen reader or switch, which can't hold:\n          // fall back to press-twice.\n          if (e.detail !== 0 || state === \"done\") return;\n          if (state === \"armed\") confirm();\n          else {\n            setState(\"armed\");\n            later(() => setState(\"idle\"), 4000);\n          }\n        }}\n        onContextMenu={(e: React.MouseEvent) => e.preventDefault()}\n        className={cn(\n          \"group/hold relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center overflow-hidden whitespace-nowrap border font-medium tracking-[-0.005em] [-webkit-touch-callout:none]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"shadow-[var(--shadow)] transition-[background-color,border-color,color,scale] duration-150 ease-out\",\n          size === \"sm\" ? \"h-7 rounded-md px-2.5 text-[12px]\" : \"h-8 rounded-lg px-3 text-[12.5px]\",\n          // Once done the border takes the fill's color, so the button reads as one solid shape.\n          state === \"done\"\n            ? danger ? \"border-danger bg-danger text-frame\" : \"border-fg bg-fg text-frame\"\n            : state === \"armed\"\n            ? danger ? \"border-danger/50 bg-danger-soft text-danger\" : \"border-fg-4 bg-hover text-fg\"\n            : danger ? \"border-line-2 bg-raised text-danger hover:border-danger/40 hover:bg-danger-soft\" : \"border-line-2 bg-raised text-fg hover:border-fg-4 hover:bg-hover\",\n          // Held down: the button stays pressed for the whole hold, then eases back on release.\n          state === \"holding\" && !reduce && \"scale-[0.97]\",\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"sr-only\">{children}</span>\n        <span aria-hidden className=\"contents\">\n          {face(false)}\n          <motion.span\n            className={cn(\"pointer-events-none absolute inset-0 flex items-center justify-center\", size === \"sm\" ? \"px-2.5\" : \"px-3\", danger ? \"bg-danger text-frame\" : \"bg-fg text-frame\")}\n            style={{ clipPath: clip }}\n          >\n            {face(true)}\n          </motion.span>\n        </span>\n      </Button>\n      <span id={hintId} className=\"sr-only\">\n        Press and hold for {Math.round(duration / 100) / 10} seconds\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {state === \"done\" ? confirmedLabel : state === \"armed\" ? armedLabel : \"\"}\n      </span>\n    </>\n  );\n}\n\nfunction Row({ icon, size, className, children }: { icon: React.ReactNode; size: string; className?: string; children: React.ReactNode }) {\n  return (\n    <span className={cn(\"inline-flex items-center\", size === \"sm\" ? \"gap-1.5\" : \"gap-2\", className)}>\n      {icon && <span className={cn(\"grid shrink-0 place-items-center\", size === \"sm\" ? \"size-3.5 [&_svg]:size-3.5\" : \"size-4\")}>{icon}</span>}\n      <span>{children}</span>\n    </span>\n  );\n}\n\nfunction Tick({ reduce, animated }: { reduce: boolean; animated: boolean }) {\n  const draw = animated && !reduce;\n  return (\n    <motion.svg\n      width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden\n      initial={draw ? { scale: 0.6 } : false}\n      animate={{ scale: 1 }}\n      transition={spring.pop}\n    >\n      <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={draw ? { pathLength: 0 } : false} animate={{ pathLength: 1 }} transition={{ duration: 0.34, ease: ease.out, delay: 0.05 }} />\n    </motion.svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/hold-to-confirm"
}
