{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "two-step-confirm",
  "type": "registry:ui",
  "title": "Two-step Confirm",
  "description": "Asks once in place, springs to fit the question, then quietly takes it back.",
  "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/two-step-confirm.tsx",
      "content": "\"use client\";\nimport { Button } from \"@base-ui/react/button\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Step = \"idle\" | \"confirm\" | \"done\";\n\nexport type TwoStepConfirmProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onClick\"> & {\n  /** The first-step label, a verb: \"Delete\". */\n  children: React.ReactNode;\n  /** The second step asks, naming the action: \"Confirm delete?\". */\n  confirmLabel?: string;\n  confirmedLabel?: string;\n  /** Called on the second press. */\n  onConfirm: () => void;\n  /** Milliseconds the confirm step waits before reverting on its own. Paused while hovered. */\n  timeout?: number;\n  /** Milliseconds the confirmed state shows before returning to rest. `null` keeps it. */\n  resetAfter?: number | null;\n  icon?: React.ReactNode;\n  variant?: \"secondary\" | \"ghost\";\n  size?: \"sm\" | \"md\";\n  /** Fires when the confirm step opens or closes, so you can dim the row or show more context. */\n  onArmedChange?: (armed: boolean) => void;\n};\n\n// A second press closer than this to the first is a double-click, not a decision.\nconst DOUBLE_CLICK_GUARD = 350;\n\nexport function TwoStepConfirm({\n  children,\n  confirmLabel = \"Confirm\",\n  confirmedLabel = \"Done\",\n  onConfirm,\n  timeout = 3000,\n  resetAfter = 1500,\n  icon,\n  variant = \"secondary\",\n  size = \"md\",\n  onArmedChange,\n  disabled,\n  className,\n  ref,\n  onBlur,\n  onKeyDown,\n  onPointerEnter,\n  onPointerLeave,\n  ...rest\n}: TwoStepConfirmProps) {\n  const [step, setStep] = useState<Step>(\"idle\");\n  const [widths, setWidths] = useState<Record<Step, number> | null>(null);\n  const reduce = useReducedMotion();\n  const sizers = useRef<HTMLSpanElement>(null);\n  const countdown = useMotionValue(1);\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  const armedAt = useRef(0);\n  const timer = useRef<number>(undefined);\n\n  // Measure each step's natural width so the button can spring between them.\n  useEffect(() => {\n    const el = sizers.current;\n    if (!el) return;\n    const read = () => {\n      const [a, b, c] = Array.from(el.children) as HTMLElement[];\n      setWidths({ idle: a.offsetWidth, confirm: b.offsetWidth, done: c.offsetWidth });\n    };\n    const ro = new ResizeObserver(read);\n    Array.from(el.children).forEach((c) => ro.observe(c));\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(\n    () => () => {\n      anim.current?.stop();\n      window.clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const hovered = useRef(false);\n  const wrap = useRef<HTMLSpanElement>(null);\n  const armed = useRef(false);\n  const go = (next: Step) => {\n    const now = next === \"confirm\";\n    if (armed.current !== now) {\n      armed.current = now;\n      onArmedChange?.(now);\n    }\n    setStep(next);\n  };\n\n  const disarm = () => {\n    anim.current?.stop();\n    go(\"idle\");\n  };\n\n  const arm = () => {\n    armedAt.current = performance.now();\n    go(\"confirm\");\n    countdown.set(1);\n    anim.current?.stop();\n    anim.current = animate(countdown, 0, { duration: timeout / 1000, ease: \"linear\", onComplete: disarm });\n    // The press that armed it left the pointer on the button: start paused.\n    if (hovered.current) anim.current.pause();\n  };\n\n  const commit = () => {\n    anim.current?.stop();\n    go(\"done\");\n    onConfirm();\n    window.clearTimeout(timer.current);\n    if (resetAfter != null) timer.current = window.setTimeout(() => go(\"idle\"), resetAfter);\n  };\n\n  // A press anywhere else answers \"no\". Blur covers keyboard; this covers\n  // browsers that don't focus a button on click.\n  useEffect(() => {\n    if (step !== \"confirm\") return;\n    const onDown = (e: PointerEvent) => {\n      if (!wrap.current?.contains(e.target as Node)) {\n        anim.current?.stop();\n        armed.current = false;\n        onArmedChange?.(false);\n        setStep(\"idle\");\n      }\n    };\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [step, onArmedChange]);\n\n  const sizeCls = size === \"sm\" ? \"h-7 rounded-md px-2.5 text-[12px]\" : \"h-8 rounded-lg px-3 text-[12.5px]\";\n  const labels: Record<Step, React.ReactNode> = { idle: children, confirm: confirmLabel, done: confirmedLabel };\n  const glyphs: Record<Step, React.ReactNode> = { idle: icon, confirm: null, done: <Tick reduce={!!reduce} /> };\n\n  return (\n    <span ref={wrap} className=\"relative inline-flex shrink-0 align-middle\">\n      {/* Invisible copies of each step, measured for the width spring. */}\n      <span ref={sizers} aria-hidden className=\"pointer-events-none invisible absolute left-0 top-0 flex w-max whitespace-nowrap\">\n        {([\"idle\", \"confirm\", \"done\"] as const).map((k) => (\n          <span key={k} className={cn(\"inline-flex shrink-0 items-center border font-medium tracking-[-0.005em]\", sizeCls)}>\n            <Row glyph={glyphs[k] ? <span /> : null} size={size}>{labels[k]}</Row>\n          </span>\n        ))}\n      </span>\n\n      <Button\n        ref={ref as React.Ref<HTMLElement>}\n        type=\"button\"\n        data-state={step}\n        data-size={size}\n        data-variant={variant}\n        disabled={disabled}\n        render={\n          <motion.button\n            initial={false}\n            animate={widths ? { width: widths[step] } : undefined}\n            transition={reduce ? { duration: 0 } : spring.snappy}\n          />\n        }\n        onClick={() => {\n          if (step === \"idle\") arm();\n          else if (step === \"confirm\" && performance.now() - armedAt.current > DOUBLE_CLICK_GUARD) commit();\n        }}\n        onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {\n          onKeyDown?.(e);\n          if (e.key === \"Escape\" && step === \"confirm\") {\n            e.stopPropagation();\n            disarm();\n          }\n        }}\n        onBlur={(e: React.FocusEvent<HTMLButtonElement>) => {\n          onBlur?.(e);\n          if (step === \"confirm\") disarm();\n        }}\n        // Reading the question shouldn't cost you the chance to answer it.\n        onPointerEnter={(e: React.PointerEvent<HTMLButtonElement>) => {\n          onPointerEnter?.(e);\n          hovered.current = true;\n          anim.current?.pause();\n        }}\n        onPointerLeave={(e: React.PointerEvent<HTMLButtonElement>) => {\n          onPointerLeave?.(e);\n          hovered.current = false;\n          if (step === \"confirm\") anim.current?.play();\n        }}\n        className={cn(\n          \"group/two relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden whitespace-nowrap border font-medium tracking-[-0.005em]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,color,scale,box-shadow] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n          sizeCls,\n          step === \"confirm\"\n            ? \"border-danger bg-danger text-frame shadow-[var(--shadow)] hover:bg-danger/90\"\n            : step === \"done\"\n              ? variant === \"ghost\" ? \"border-transparent text-fg-2\" : \"border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)]\"\n              : variant === \"ghost\"\n                ? \"border-transparent text-fg-2 hover:bg-hover hover:text-fg\"\n                : \"border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"sr-only\">{labels[step]}</span>\n        <span aria-hidden className=\"grid place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={step}\n              className=\"col-start-1 row-start-1 flex\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -8, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n            >\n              <Row glyph={glyphs[step]} size={size}>{labels[step]}</Row>\n            </motion.span>\n          </AnimatePresence>\n        </span>\n\n        {/* How long the question stays open: a hairline that drains, and stops while you read. */}\n        {step === \"confirm\" && (\n          <motion.span\n            aria-hidden\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[2px] origin-left bg-frame/35\"\n            style={{ scaleX: countdown }}\n          />\n        )}\n      </Button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {step === \"confirm\" ? `${confirmLabel} Press again to confirm, or Escape to cancel.` : step === \"done\" ? confirmedLabel : \"\"}\n      </span>\n    </span>\n  );\n}\n\nfunction Row({ glyph, size, children }: { glyph: React.ReactNode; size: string; children: React.ReactNode }) {\n  return (\n    <span className={cn(\"inline-flex items-center\", size === \"sm\" ? \"gap-1.5\" : \"gap-2\")}>\n      {glyph && <span className={cn(\"grid shrink-0 place-items-center\", size === \"sm\" ? \"size-3.5 [&_svg]:size-3.5\" : \"size-4\")}>{glyph}</span>}\n      <span>{children}</span>\n    </span>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/two-step-confirm"
}
