{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-confirm",
  "type": "registry:ui",
  "title": "Hold to confirm",
  "description": "Confirm a destructive action by holding, not tapping.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/hold-to-confirm/hold-to-confirm.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useEffectEvent, useId, useRef, useState } from \"react\";\nimport type {\n  ButtonHTMLAttributes,\n  KeyboardEvent as ReactKeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue, TargetAndTransition } from \"motion/react\";\nimport { Trash2 } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/**\n * A button that commits only after it is held, for destructive or hard to undo actions where a stray tap must not count. A fill tracks the hold on a\n * linear timeline; letting go early rewinds it on a spring, and finishing morphs the label and icon into a done state. Space and Enter can be held too.\n * Prefer a regular confirmation dialog when people need to read consequences first; use this when the consequence is already on screen.\n */\nexport interface HoldToConfirmProps extends Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"children\" | \"onClick\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\" | \"onAnimationEnd\"\n> {\n  /** The instruction and the action, for example “Hold to delete project”. */\n  label: string;\n  /** Shown once the hold completes, for example “Deleted”. */\n  confirmedLabel?: string;\n  /** Called once when the hold completes. */\n  onConfirm: () => void;\n  /** Hold length in milliseconds. */\n  duration?: number;\n  icon?: ReactNode;\n  /** `accent` (default) fills with the selected accent; `danger` is for an irreversible action; `neutral` fills with the foreground. */\n  tone?: \"accent\" | \"danger\" | \"neutral\";\n  /** Controls the done state. Set it back to false to reset the button; leave it undefined to let the button keep its own state. */\n  confirmed?: boolean;\n  /** Reports when a hold starts and ends, for surrounding hints such as “Keep holding”. */\n  onHoldChange?: (holding: boolean) => void;\n}\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = {\n  ...iconIn,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst fadeIn: TargetAndTransition = { opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconEnter = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: enter },\n  filter: { duration: motionTokens.duration.fast, ease: enter },\n};\n\n/** The tick draws itself from its short stroke, the way a hand would write it. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return (\n    <svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <motion.path\n        d=\"M4 12.5l5 5L20 6.5\"\n        initial={reduced ? false : { pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        transition={{\n          pathLength: { duration: 0.32, ease: enter, delay: 0.08 },\n          opacity: { duration: 0.05, delay: 0.08 },\n        }}\n      />\n    </svg>\n  );\n}\n\n/* The button hugs its label; the fill is a second, inverted copy of the face revealed by clip-path, so the text flips colour\n   exactly at the edge. The fill covers the border too and keeps its own rounded corners; the clip only ever cuts a straight\n   leading edge. The default tone follows the selected accent; danger stays available for a truly irreversible action. */\nconst buttonClass =\n  \"relative inline-flex min-h-control-md cursor-pointer touch-manipulation items-center justify-center rounded-button border border-(--hold-border) bg-surface px-5 text-sm leading-body font-medium text-(--hold-ink) select-none [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [--hold-border:var(--line-strong)] [--hold-fill:var(--accent)] [--hold-ink:var(--ink)] [--hold-on-fill:var(--on-accent)] transition-[background-color,border-color,opacity] duration-fast ease-standard hover:enabled:not-aria-disabled:bg-[color-mix(in_oklab,var(--hold-fill)_6%,var(--surface))] disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:enabled:cursor-default data-[tone=danger]:[--hold-border:color-mix(in_oklab,var(--red)_32%,var(--line))] data-[tone=danger]:[--hold-fill:var(--red)] data-[tone=danger]:[--hold-ink:var(--red)] data-[tone=danger]:[--hold-on-fill:var(--page)] data-[tone=neutral]:[--hold-fill:var(--ink)] data-[tone=neutral]:[--hold-on-fill:var(--page)] motion-reduce:transition-none\";\n\ntype FaceProps = {\n  icon: ReactNode;\n  text: string;\n  done: boolean;\n  width: MotionValue<number | \"auto\">;\n  reduced: boolean;\n  measure?: (node: HTMLSpanElement | null) => void;\n};\n\n/** Icon and label. The button renders it twice: once on the surface and once inside the fill, so the text changes colour exactly at the fill edge. */\nfunction Face({ icon, text, done, width, reduced, measure }: FaceProps) {\n  return (\n    <span className=\"inline-flex items-center gap-2 whitespace-nowrap\">\n      <span className=\"grid size-[18px] flex-none place-items-center\">\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={done ? \"done\" : \"idle\"}\n            className=\"grid place-items-center [grid-area:1/1] [&_svg]:size-[18px]\"\n            initial={reduced ? fadeIn : iconIn}\n            animate={rest}\n            exit={reduced ? fadeOut : iconOut}\n            transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}\n          >\n            {done ? <DrawnCheck reduced={reduced} /> : icon}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <motion.span className=\"relative inline-flex min-w-0 [clip-path:inset(-.6em_-3px)]\" style={{ width }}>\n        {measure && (\n          <span ref={measure} className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\">\n            {text}\n          </span>\n        )}\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={text}\n            className=\"block whitespace-nowrap\"\n            initial={reduced ? fadeIn : textIn}\n            animate={rest}\n            exit={reduced ? fadeOut : textOut}\n            transition={{\n              duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard,\n              ease: enter,\n            }}\n          >\n            {text}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n    </span>\n  );\n}\n\n/** Springs the label frame to the width of new text, so the button morphs instead of snapping. A late web font or a reflow follows instantly. */\nfunction useLabelWidth(reduced: boolean) {\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const [node, setNode] = useState<HTMLSpanElement | null>(null);\n  useEffect(() => {\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let lastText: string | null = null;\n    let sizing: AnimationPlaybackControls | null = null;\n    const observer = new ResizeObserver(([entry]) => {\n      // Layout width, not the painted box: the done label is measured while the pressed button is still scaled down.\n      const next = entry?.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      const text = node.textContent;\n      const morph = lastText !== null && lastText !== text && !reduced && typeof width.get() === \"number\";\n      lastText = text;\n      sizing?.stop();\n      if (morph) sizing = animate(width, next, motionTokens.spring.morph);\n      else width.jump(next);\n    });\n    observer.observe(node);\n    return () => {\n      observer.disconnect();\n      sizing?.stop();\n    };\n  }, [node, reduced, width]);\n  return [width, setNode] as const;\n}\n\nexport function HoldToConfirm({\n  label,\n  confirmedLabel = \"Done\",\n  onConfirm,\n  duration = 1200,\n  icon = <Trash2 strokeWidth={1.75} />,\n  tone = \"accent\",\n  confirmed,\n  onHoldChange,\n  className,\n  disabled,\n  ...props\n}: HoldToConfirmProps) {\n  const reduced = useReducedMotion() ?? false;\n  const hintId = useId();\n  const [ownDone, setOwnDone] = useState(false);\n  const [completions, setCompletions] = useState(0);\n  const done = confirmed ?? ownDone;\n  const [holding, setHolding] = useState(false);\n  const progress = useMotionValue(0);\n  const scale = useMotionValue(1);\n  const clipPath = useTransform(\n    progress,\n    (value) => `inset(0 ${((1 - Math.min(1, Math.max(0, value))) * 100).toFixed(3)}% 0 0)`,\n  );\n  const [width, measure] = useLabelWidth(reduced);\n  const source = useRef<\"pointer\" | \"key\" | null>(null);\n  const pointerType = useRef(\"mouse\");\n  const fill = useRef<AnimationPlaybackControls | null>(null);\n  const press = useRef<AnimationPlaybackControls | null>(null);\n  const button = useRef<HTMLButtonElement>(null);\n  const text = done ? confirmedLabel : label;\n  const seconds = (duration / 1000).toLocaleString(\"en-US\", { maximumFractionDigits: 1 });\n\n  function pressTo(pressed: boolean) {\n    press.current?.stop();\n    if (reduced) {\n      scale.jump(1);\n      return;\n    }\n    const depth = (button.current?.offsetWidth ?? 0) > 220 ? 0.985 : 0.97;\n    press.current = animate(scale, pressed ? depth : 1, motionTokens.spring.snappy);\n  }\n\n  function rewind() {\n    fill.current?.stop();\n    if (reduced) progress.jump(0);\n    else fill.current = animate(progress, 0, { ...motionTokens.spring.smooth, velocity: 0 });\n  }\n\n  function stopHolding() {\n    source.current = null;\n    setHolding(false);\n    onHoldChange?.(false);\n    pressTo(false);\n  }\n\n  function complete() {\n    if (!source.current) return;\n    stopHolding();\n    if (pointerType.current === \"touch\") navigator.vibrate?.(12);\n    setOwnDone(true);\n    setCompletions((count) => count + 1);\n    onConfirm();\n  }\n\n  /** Starts or resumes the fill from wherever it is, so a quick re-press after letting go carries on instead of starting over. */\n  function begin(from: \"pointer\" | \"key\") {\n    if (done || disabled || source.current) return;\n    source.current = from;\n    setHolding(true);\n    onHoldChange?.(true);\n    pressTo(true);\n    fill.current?.stop();\n    fill.current = animate(progress, 1, {\n      duration: ((1 - progress.get()) * duration) / 1000,\n      ease: \"linear\",\n      onComplete: complete,\n    });\n  }\n\n  function release() {\n    if (!source.current) return;\n    stopHolding();\n    rewind();\n  }\n\n  // A reset rewinds the fill as the label morphs back; a confirmation the parent declined rewinds too.\n  const syncFill = useEffectEvent(() => {\n    if (done && progress.get() < 1 && !source.current) {\n      fill.current?.stop();\n      if (reduced) progress.jump(1);\n      else fill.current = animate(progress, 1, motionTokens.spring.smooth);\n    }\n    if (!done && progress.get() > 0 && !source.current) rewind();\n  });\n  useEffect(() => {\n    syncFill();\n  }, [done, completions]);\n  useEffect(\n    () => () => {\n      fill.current?.stop();\n      press.current?.stop();\n    },\n    [],\n  );\n\n  function onPointerDown(event: ReactPointerEvent<HTMLButtonElement>) {\n    if (!event.isPrimary || event.button !== 0) return;\n    pointerType.current = event.pointerType;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    begin(\"pointer\");\n  }\n  function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>) {\n    if (source.current !== \"pointer\") return;\n    // Sliding well off the button cancels, the way a native press does.\n    const box = event.currentTarget.getBoundingClientRect(),\n      slack = 24;\n    if (\n      event.clientX < box.left - slack ||\n      event.clientX > box.right + slack ||\n      event.clientY < box.top - slack ||\n      event.clientY > box.bottom + slack\n    )\n      release();\n  }\n  function onKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (event.key !== \" \" && event.key !== \"Enter\") return;\n    event.preventDefault();\n    if (!event.repeat) begin(\"key\");\n  }\n  function onKeyUp(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (event.key !== \" \" && event.key !== \"Enter\") return;\n    event.preventDefault();\n    if (source.current === \"key\") release();\n  }\n\n  return (\n    <>\n      <motion.button\n        {...props}\n        ref={button}\n        type=\"button\"\n        className={cn(buttonClass, className)}\n        data-tone={tone}\n        data-state={done ? \"done\" : holding ? \"holding\" : \"idle\"}\n        disabled={disabled}\n        aria-disabled={done || undefined}\n        aria-label={text}\n        aria-describedby={done ? undefined : hintId}\n        style={{ scale }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={release}\n        onPointerCancel={release}\n        onLostPointerCapture={release}\n        onKeyDown={onKeyDown}\n        onKeyUp={onKeyUp}\n        onBlur={release}\n        onContextMenu={(event) => event.preventDefault()}\n      >\n        <Face icon={icon} text={text} done={done} width={width} reduced={reduced} measure={measure} />\n        <motion.span\n          className=\"pointer-events-none absolute -inset-px flex items-center justify-center rounded-button bg-(--hold-fill) text-(--hold-on-fill)\"\n          style={{ clipPath }}\n          aria-hidden=\"true\"\n        >\n          <Face icon={icon} text={text} done={done} width={width} reduced={reduced} />\n        </motion.span>\n      </motion.button>\n      <span\n        id={hintId}\n        className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)]\"\n      >{`Press and hold for ${seconds} seconds to confirm. With a keyboard, hold Space or Enter.`}</span>\n      <output className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)]\" aria-live=\"polite\">\n        {done ? confirmedLabel : \"\"}\n      </output>\n    </>\n  );\n}\n\nexport default HoldToConfirm;\n",
      "type": "registry:component",
      "target": "@components/iris/hold-to-confirm/hold-to-confirm.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/hold-to-confirm",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "HoldToConfirm",
    "tags": [
      "hold",
      "long press",
      "confirm"
    ],
    "docs": "https://irisui.dev/components/hold-to-confirm"
  }
}
