{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confirm-morph",
  "type": "registry:ui",
  "title": "Confirm morph",
  "description": "A destructive button that morphs into an inline confirmation, a spinner, and a result with undo.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/confirm-morph/confirm-morph.tsx",
      "type": "registry:component",
      "target": "@components/xzero/confirm-morph/confirm-morph.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, Ref } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue, Transition, Variants } from \"motion/react\";\nimport { CircleAlert, LoaderCircle } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./confirm-morph.module.css\";\n\n/** Where the control is in its life: resting, asking, working, finished, or failed. */\nexport type ConfirmMorphState = \"idle\" | \"confirming\" | \"pending\" | \"done\" | \"error\";\n\n/** How the question is answered: a press, a press held until the fill completes, or a press followed by a short countdown. */\nexport type ConfirmMorphMode = \"press\" | \"hold\" | \"countdown\";\n\n/**\n * A button for destructive or important actions that asks in place. Pressing it morphs the same pill into an inline question\n * with Cancel and Confirm, then into a spinner, then into a result with Undo. The width springs to each face while the faces\n * crossfade at their natural width, so text never squashes and nothing around the control jumps. Escape, an outside press,\n * or the timeout all return it to rest.\n * Use it where a modal dialog would be heavy: deleting a project, revoking access, discarding a draft.\n */\nexport interface ConfirmMorphProps {\n  /** The resting label, such as \"Delete project\". */\n  label: ReactNode;\n  /** A plain icon before the resting label. */\n  icon?: ReactNode;\n  /** The question shown while confirming, such as \"Delete 3 files?\". Defaults to the label with a question mark. */\n  prompt?: ReactNode;\n  confirmLabel?: string;\n  cancelLabel?: string;\n  /** Shown beside the spinner while `onConfirm` resolves. */\n  pendingLabel?: string;\n  doneLabel?: string;\n  errorLabel?: string;\n  retryLabel?: string;\n  undoLabel?: string;\n  /** Shown beside the spinner while `onUndo` resolves. */\n  undoingLabel?: string;\n  /**\n   * `danger` (default) gives the confirm button the danger colour, for actions that destroy something. `neutral` gives it the\n   * accent, for important actions that can be reversed. The resting button is neutral either way.\n   */\n  tone?: \"danger\" | \"neutral\";\n  /** `press` confirms on a press. `hold` asks for a press held for `holdDuration`. `countdown` confirms, then counts down `countdown` seconds that Cancel or Escape can still stop. */\n  confirmMode?: ConfirmMorphMode;\n  /** Milliseconds the confirm button must be held in `hold` mode. */\n  holdDuration?: number;\n  /** Seconds counted down before the action runs in `countdown` mode. */\n  countdown?: number;\n  /** Text before the seconds in `countdown` mode, such as \"Deleting in\". Defaults to the pending label followed by \"in\". */\n  countdownLabel?: string;\n  /** Runs on confirm. Return a promise to show the pending face; a rejection shows the error face with Retry. */\n  onConfirm?: () => void | Promise<unknown>;\n  /** Offering it adds Undo to the result. Return a promise to show a pending face while it runs. */\n  onUndo?: () => void | Promise<unknown>;\n  onCancel?: () => void;\n  /** Controlled state. Pair it with `onStateChange`. */\n  state?: ConfirmMorphState;\n  /** Starting state when uncontrolled. */\n  defaultState?: ConfirmMorphState;\n  onStateChange?: (state: ConfirmMorphState) => void;\n  /** Milliseconds before an unanswered question returns to rest. Resting the pointer on the control pauses it. 0 turns it off. */\n  confirmTimeout?: number;\n  /** Milliseconds a result stays before returning to rest. Resting the pointer on the control pauses it. 0 turns it off. */\n  resultTimeout?: number;\n  /** A press outside the control cancels an open question. Defaults to true. */\n  cancelOnOutsidePress?: boolean;\n  disabled?: boolean;\n  className?: string;\n  /** Receives the root element, which also takes focus while the action is pending. */\n  ref?: Ref<HTMLDivElement>;\n}\n\n/** A press this soon after the question appears is the tail of the press that opened it, not an answer. */\nconst ARM_MS = 280;\nconst { blur } = motionTokens;\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\n/** Duration springs restated as stiffness and damping, so a retarget mid-flight keeps the velocity it already has. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\n/** Growing follows new content with a little life; shrinking settles without overshoot. */\nconst GROW = physical(motionTokens.spring.morph), SHRINK = physical(motionTokens.spring.smooth);\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\n/*\n * Faces never move and never scale: each one sits at its natural width, centred in the pill, so the baseline stays put and\n * the pill's clip reveals it as the width springs. The old face fades out quickly; the new one's parts arrive in reading\n * order, each clearing a small blur.\n */\nconst faceVariants: Variants = {\n  hidden: { opacity: 1 },\n  shown: { opacity: 1, transition: { staggerChildren: motionTokens.stagger.item, delayChildren: .05 } },\n  gone: { opacity: 0, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },\n};\nconst partVariants: Variants = {\n  hidden: { opacity: 0, filter: `blur(${blur.soft}px)` },\n  shown: { opacity: 1, filter: \"blur(0px)\", transition: { duration: .26, ease: enter } },\n};\nconst reducedFace: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .14 } }, gone: { opacity: 0, transition: { duration: .1 } } };\nconst reducedPart: Variants = { hidden: { opacity: 1 }, shown: { opacity: 1 } };\n\ntype FaceKey = ConfirmMorphState | \"counting\";\n\nfunction Face({ id, reduced, onSize, children, labelledBy }: { id: FaceKey; reduced: boolean; onSize: (id: FaceKey, width: number) => void; children: ReactNode; labelledBy?: string }) {\n  const ref = useRef<HTMLDivElement>(null);\n  const present = useIsPresent();\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node || !present) return;\n    // Fractional widths, so the spring lands exactly where the pill's natural width is and the hand back to auto is seamless.\n    const report = () => onSize(id, node.getBoundingClientRect().width);\n    report();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(report);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [id, onSize, present]);\n  return <motion.div ref={ref} className={styles.face} data-face={id} role={labelledBy ? \"group\" : undefined} aria-labelledby={labelledBy}\n    variants={reduced ? reducedFace : faceVariants} initial=\"hidden\" animate=\"shown\" exit=\"gone\" inert={!present || undefined}>{children}</motion.div>;\n}\n\nfunction Part({ reduced, className, children }: { reduced: boolean; className?: string; children: ReactNode }) {\n  return <motion.span className={[styles.part, className].filter(Boolean).join(\" \")} variants={reduced ? reducedPart : partVariants}>{children}</motion.span>;\n}\n\n/** A success disc that pops in with a tick drawing across it, the moment the action lands. */\nfunction Check({ reduced }: { reduced: boolean }) {\n  return <svg className={styles.check} viewBox=\"0 0 18 18\" fill=\"none\" aria-hidden=\"true\">\n    <motion.circle className={styles.checkDisc} cx=\"9\" cy=\"9\" r=\"8\" style={{ transformOrigin: \"9px 9px\" }}\n      initial={reduced ? false : { scale: .5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ scale: physical(motionTokens.spring.snappy), opacity: { duration: .12 } }} />\n    <motion.path className={styles.checkTick} d=\"M5.6 9.3 7.8 11.4 12.4 6.7\" strokeWidth=\"1.9\" strokeLinecap=\"round\" strokeLinejoin=\"round\"\n      initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: .3, ease: enter, delay: .14 }} />\n  </svg>;\n}\n\n/** A ring that drains with the countdown. */\nfunction Ring({ progress }: { progress: MotionValue<number> }) {\n  return <svg className={styles.ring} viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\n    <circle className={styles.ringTrack} cx=\"8\" cy=\"8\" r=\"6.25\" strokeWidth=\"1.75\" />\n    <motion.circle className={styles.ringFill} cx=\"8\" cy=\"8\" r=\"6.25\" strokeWidth=\"1.75\" strokeLinecap=\"round\" style={{ pathLength: progress, rotate: -90, transformOrigin: \"8px 8px\" }} />\n  </svg>;\n}\n\n/** Seconds that swap in place: the old digit fades up and out, the new one rises in, on tabular figures so the width holds. */\nfunction Seconds({ value, reduced }: { value: number; reduced: boolean }) {\n  return <span className={styles.seconds}>\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span key={value} className={styles.digit}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${blur.subtle}px)` }}\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: `blur(${blur.subtle}px)` }}\n        transition={{ duration: .22, ease: enter }}>{value}</motion.span>\n    </AnimatePresence>\n  </span>;\n}\n\nexport function ConfirmMorph({\n  label, icon, prompt, confirmLabel = \"Delete\", cancelLabel = \"Cancel\", pendingLabel = \"Deleting\", doneLabel = \"Deleted\", errorLabel = \"Couldn’t finish\",\n  retryLabel = \"Retry\", undoLabel = \"Undo\", undoingLabel = \"Restoring\", tone = \"danger\", confirmMode = \"press\", holdDuration = 1000, countdown = 3, countdownLabel,\n  onConfirm, onUndo, onCancel, state: stateProp, defaultState = \"idle\", onStateChange, confirmTimeout = 6000, resultTimeout = 5000, cancelOnOutsidePress = true,\n  disabled = false, className, ref,\n}: ConfirmMorphProps) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const promptId = `${uid}-prompt`;\n  const holdHintId = `${uid}-hold`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, []);\n\n  const [inner, setInner] = useState<ConfirmMorphState>(defaultState);\n  const state = stateProp ?? inner;\n  const [working, setWorking] = useState<\"confirm\" | \"undo\">(\"confirm\");\n  const [counting, setCounting] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const faceKey: FaceKey = state === \"confirming\" && counting ? \"counting\" : state;\n\n  const live = useRef({ state, onStateChange, controlled: stateProp !== undefined });\n  useLayoutEffect(() => { live.current = { state, onStateChange, controlled: stateProp !== undefined }; });\n  const pendingFocus = useRef(false);\n  const run = useRef(0);\n  const askedAt = useRef(0);\n\n  const keepFocus = useCallback(() => {\n    const root = rootRef.current;\n    // Focus follows the control between faces, but only when it was already inside; a timeout never steals focus from elsewhere.\n    pendingFocus.current = !!root && (root.contains(document.activeElement) || document.activeElement === document.body);\n  }, []);\n\n  const go = useCallback((next: ConfirmMorphState) => {\n    if (next !== \"confirming\") setCounting(false);\n    if (next === live.current.state) return;\n    keepFocus();\n    if (next === \"confirming\") askedAt.current = performance.now();\n    if (!live.current.controlled) setInner(next);\n    live.current.state = next;\n    live.current.onStateChange?.(next);\n  }, [keepFocus]);\n\n  // A controlled parent can move the state on its own; the countdown never survives that.\n  const [seenState, setSeenState] = useState(state);\n  if (seenState !== state) {\n    setSeenState(state);\n    if (state !== \"confirming\") setCounting(false);\n  }\n\n  const toIdle = useCallback(() => { run.current++; go(\"idle\"); }, [go]);\n\n  const perform = useCallback(async (kind: \"confirm\" | \"undo\") => {\n    const handler = kind === \"confirm\" ? onConfirm : onUndo;\n    const token = ++run.current;\n    setWorking(kind);\n    setCounting(false);\n    let result: void | Promise<unknown> | undefined;\n    try { result = handler?.(); }\n    catch { go(\"error\"); setAnnouncement(errorLabel); return; }\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      go(\"pending\");\n      setAnnouncement(kind === \"confirm\" ? pendingLabel : undoingLabel);\n      try { await result; }\n      catch {\n        if (token !== run.current) return;\n        go(\"error\");\n        setAnnouncement(errorLabel);\n        return;\n      }\n      if (token !== run.current) return;\n    }\n    if (kind === \"undo\") { go(\"idle\"); setAnnouncement(\"Undone\"); return; }\n    go(\"done\");\n    setAnnouncement(onUndo ? `${doneLabel}. ${undoLabel} is available.` : doneLabel);\n  }, [doneLabel, errorLabel, go, onConfirm, onUndo, pendingLabel, undoLabel, undoingLabel]);\n\n  const cancel = useCallback(() => { onCancel?.(); toIdle(); setAnnouncement(\"Cancelled\"); }, [onCancel, toIdle]);\n  const expire = useRef(() => {});\n  useLayoutEffect(() => { expire.current = () => { if (live.current.state === \"confirming\") cancel(); else toIdle(); }; });\n\n  /* The shape: one pill whose width springs to whichever face is current. At rest it is auto, so it renders right before hydration. */\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const target = useRef(0);\n  const flight = useRef(0);\n  // Only the present face reports (a leaving face stops observing), so every report is the current face's width.\n  const onFaceSize = useCallback((_id: FaceKey, w: number) => {\n    if (Math.abs(w - target.current) < .25) return;\n    const from = target.current;\n    target.current = w;\n    if (!from || reduced) { flight.current++; width.jump(\"auto\"); return; }\n    const now = width.get();\n    if (now === \"auto\") width.jump(from);\n    const token = ++flight.current;\n    animate(width, w, w > (typeof now === \"number\" ? now : from) ? GROW : SHRINK).then(() => { if (token === flight.current) width.jump(\"auto\"); });\n  }, [reduced, width]);\n\n  /* The timeout runs as an invisible clock. A pointer resting on the control holds it, and so does a hidden tab. */\n  const drain = useMotionValue(1);\n  const clock = useRef<AnimationPlaybackControls | null>(null);\n  const holds = useRef({ hover: false, hidden: false });\n  const timeout = state === \"confirming\" && !counting ? confirmTimeout : state === \"done\" || state === \"error\" ? resultTimeout : 0;\n  const sync = useCallback(() => {\n    const control = clock.current;\n    if (!control) return;\n    const held = holds.current.hover || holds.current.hidden;\n    if (held) control.pause(); else control.play();\n  }, []);\n  useEffect(() => {\n    if (!timeout) return;\n    drain.jump(1);\n    const control = animate(drain, 0, { duration: timeout / 1000, ease: \"linear\" });\n    clock.current = control;\n    control.then(() => { if (clock.current === control) { clock.current = null; expire.current(); } });\n    sync();\n    return () => { if (clock.current === control) clock.current = null; control.stop(); };\n  }, [drain, state, sync, timeout]);\n  useEffect(() => {\n    const onVisibility = () => { holds.current.hidden = document.hidden; sync(); };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => document.removeEventListener(\"visibilitychange\", onVisibility);\n  }, [sync]);\n\n  /* Countdown: a short grace period after the answer, drawn as a draining ring and ticking seconds. */\n  const remaining = useMotionValue(1);\n  const [seconds, setSeconds] = useState(countdown);\n  const performRef = useRef(perform);\n  useLayoutEffect(() => { performRef.current = perform; });\n  useMotionValueEvent(remaining, \"change\", value => setSeconds(Math.max(1, Math.ceil(value * countdown))));\n  useEffect(() => {\n    if (!counting) return;\n    remaining.jump(1);\n    const control = animate(remaining, 0, { duration: countdown, ease: \"linear\" });\n    control.then(() => { void performRef.current(\"confirm\"); });\n    return () => control.stop();\n  }, [counting, countdown, remaining]);\n\n  /* Hold: the confirm button fills with the danger colour from the left while pressed, and drains back if let go early. */\n  const hold = useMotionValue(0);\n  const holding = useRef<AnimationPlaybackControls | null>(null);\n  const fillClip = useTransform(hold, value => `inset(0 ${(1 - value) * 100}% 0 0 round 999px)`);\n  const startHold = () => {\n    if (holding.current) return;\n    const control = animate(hold, 1, { duration: holdDuration / 1000 * (1 - hold.get()), ease: \"linear\" });\n    holding.current = control;\n    control.then(() => {\n      if (holding.current !== control) return;\n      holding.current = null;\n      void perform(\"confirm\");\n    });\n  };\n  const releaseHold = () => {\n    const control = holding.current;\n    if (!control) return;\n    holding.current = null;\n    control.stop();\n    if (hold.get() < 1) {\n      animate(hold, 0, reduced ? { duration: 0 } : { type: \"spring\", visualDuration: .3, bounce: 0 });\n      setAnnouncement(`Keep holding to ${confirmLabel.toLowerCase()}`);\n    }\n  };\n  useEffect(() => { if (state !== \"confirming\") { holding.current?.stop(); holding.current = null; hold.jump(0); } }, [hold, state]);\n\n  const answer = () => {\n    if (performance.now() - askedAt.current < ARM_MS) return;\n    if (confirmMode === \"countdown\") {\n      keepFocus();\n      setCounting(true);\n      setAnnouncement(`${countdownLabel ?? `${pendingLabel} in`} ${countdown} seconds`);\n      return;\n    }\n    void perform(\"confirm\");\n  };\n\n  // An outside press answers the question with no.\n  useEffect(() => {\n    if (state !== \"confirming\" || !cancelOnOutsidePress) return;\n    const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) cancel(); };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [cancel, cancelOnOutsidePress, state]);\n\n  // Focus moves with the morph: Confirm while asking, the root while counting or working, Undo or Retry on a result, the trigger at rest.\n  useLayoutEffect(() => {\n    if (!pendingFocus.current) return;\n    pendingFocus.current = false;\n    const root = rootRef.current;\n    if (!root) return;\n    const face = root.querySelector<HTMLElement>(`[data-face=\"${faceKey}\"]`);\n    const autofocus = face?.querySelector<HTMLElement>(\"[data-autofocus]:not(:disabled)\");\n    (autofocus ?? root).focus({ preventScroll: true });\n  }, [faceKey]);\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      if (state === \"confirming\") { event.preventDefault(); event.stopPropagation(); cancel(); }\n      else if (state === \"done\" || state === \"error\") { event.preventDefault(); event.stopPropagation(); toIdle(); }\n      return;\n    }\n    // Enter answers yes from anywhere in the question except a button of its own (Cancel keeps meaning Cancel).\n    if (event.key === \"Enter\" && state === \"confirming\" && !event.repeat && !(event.target instanceof HTMLButtonElement)) {\n      event.preventDefault();\n      if (counting) void perform(\"confirm\");\n      else if (confirmMode !== \"hold\") answer();\n    }\n  };\n\n  const holdKeys = (event: ReactKeyboardEvent<HTMLButtonElement>, down: boolean) => {\n    if (event.key !== \"Enter\" && event.key !== \" \") return;\n    event.preventDefault();\n    if (down) { if (!event.repeat) startHold(); }\n    else releaseHold();\n  };\n  const holdPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {\n    if (event.button !== 0) return;\n    event.currentTarget.setPointerCapture?.(event.pointerId);\n    startHold();\n  };\n\n  const shownPrompt = prompt ?? <>{label}?</>;\n  const shownCountdown = countdownLabel ?? `${pendingLabel} in`;\n  const confirmButton = confirmMode === \"hold\"\n    ? <button type=\"button\" className={styles.confirm} data-autofocus aria-describedby={holdHintId}\n      onPointerDown={holdPointer} onPointerUp={releaseHold} onPointerCancel={releaseHold} onLostPointerCapture={releaseHold}\n      onKeyDown={event => holdKeys(event, true)} onKeyUp={event => holdKeys(event, false)} onBlur={releaseHold} onContextMenu={event => event.preventDefault()}>\n      <span>{confirmLabel}</span>\n      <motion.span className={styles.fill} style={{ clipPath: fillClip }} aria-hidden=\"true\"><span>{confirmLabel}</span></motion.span>\n    </button>\n    : <button type=\"button\" className={styles.confirm} data-autofocus onClick={answer} onKeyDown={event => { if (event.repeat) event.preventDefault(); }}>{confirmLabel}</button>;\n\n  const face = (() => {\n    switch (faceKey) {\n      case \"confirming\": return <>\n        <Part reduced={reduced} className={styles.prompt}><span id={promptId}>{shownPrompt}</span></Part>\n        <Part reduced={reduced}><button type=\"button\" className={styles.secondary} onClick={cancel}>{cancelLabel}</button></Part>\n        <Part reduced={reduced}>{confirmButton}</Part>\n        {confirmMode === \"hold\" && <span id={holdHintId} className={styles.srOnly}>Press and hold</span>}\n      </>;\n      case \"counting\": return <>\n        <Part reduced={reduced} className={styles.status}>\n          <Ring progress={remaining} />\n          <span className={styles.statusText}>{shownCountdown} <Seconds value={seconds} reduced={reduced} /></span>\n        </Part>\n        <Part reduced={reduced}><button type=\"button\" className={styles.secondary} onClick={cancel}>{cancelLabel}</button></Part>\n      </>;\n      case \"pending\": return <Part reduced={reduced} className={styles.status}>\n        <LoaderCircle className={styles.spinner} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        <span className={styles.statusText}>{working === \"undo\" ? undoingLabel : pendingLabel}</span>\n      </Part>;\n      case \"done\": return <>\n        <Part reduced={reduced} className={styles.status}><Check reduced={reduced} /><span className={styles.statusText}>{doneLabel}</span></Part>\n        {onUndo && <Part reduced={reduced}><button type=\"button\" className={styles.secondary} data-autofocus onClick={() => void perform(\"undo\")}>{undoLabel}</button></Part>}\n      </>;\n      case \"error\": return <>\n        <Part reduced={reduced} className={styles.status}><CircleAlert className={styles.alert} size={16} strokeWidth={1.75} aria-hidden=\"true\" /><span className={styles.statusText}>{errorLabel}</span></Part>\n        <Part reduced={reduced}><button type=\"button\" className={styles.secondary} data-autofocus onClick={() => void perform(working)}>{retryLabel}</button></Part>\n      </>;\n      default: return <Part reduced={reduced}><button type=\"button\" className={styles.trigger} data-autofocus disabled={disabled}\n        onClick={() => { setAnnouncement(typeof shownPrompt === \"string\" ? shownPrompt : \"\"); go(\"confirming\"); }}>\n        {icon && <span className={styles.icon} aria-hidden=\"true\">{icon}</span>}\n        <span>{label}</span>\n      </button></Part>;\n    }\n  })();\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} data-state={state} data-face={faceKey} data-tone={tone} data-mode={confirmMode}\n    data-disabled={disabled || undefined} tabIndex={-1} onKeyDown={onKeyDown} aria-busy={state === \"pending\" || undefined}\n    onPointerEnter={() => { holds.current.hover = true; sync(); }} onPointerLeave={() => { holds.current.hover = false; sync(); }}\n    onPointerCancel={() => { holds.current.hover = false; sync(); }}>\n    <motion.div className={styles.surface} style={{ width }}>\n      <AnimatePresence initial={false}>\n        <Face key={faceKey} id={faceKey} reduced={reduced} onSize={onFaceSize} labelledBy={faceKey === \"confirming\" ? promptId : undefined}>{face}</Face>\n      </AnimatePresence>\n    </motion.div>\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n}\n\nexport default ConfirmMorph;\n"
    },
    {
      "path": "registry/components/confirm-morph/confirm-morph.module.css",
      "type": "registry:component",
      "target": "@components/xzero/confirm-morph/confirm-morph.module.css",
      "content": "/* The root holds the control in the flow of the page; the pill inside it springs between faces. */\n.root {\n  position: relative;\n  display: inline-flex;\n  min-width: 0;\n  max-width: 100%;\n  vertical-align: middle;\n  color: var(--foreground);\n  font-family: var(--font-body);\n  font-size: var(--text-sm);\n  letter-spacing: var(--tracking-body);\n  line-height: 20px;\n  -webkit-tap-highlight-color: transparent;\n}\n\n/*\n * One neutral pill for every state. Only its width moves; corners, text and border never scale, so nothing distorts.\n * Every face sits at its natural width against the pill's leading edge, so text never slides or squeezes: the question\n * starts where the label was, and the springing edge reveals the rest. The border is an overlay so it never changes a measurement.\n */\n.surface {\n  position: relative;\n  display: flex;\n  height: var(--control-height-sm);\n  max-width: 100%;\n  justify-content: flex-start;\n  overflow: clip;\n  border-radius: var(--radius-pill);\n  background: var(--surface-raised);\n  box-shadow: var(--shadow-resting);\n  transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard);\n}\n.surface::after { content: \"\"; position: absolute; inset: 0; border: 1px solid var(--border); border-radius: inherit; pointer-events: none; transition: border-color var(--duration-standard) var(--ease-standard); }\n.root[data-disabled][data-state=\"idle\"] .surface { box-shadow: none; }\n.root[data-disabled][data-state=\"idle\"] .surface::after { border-color: var(--border-subtle); }\n\n.face { display: flex; flex: none; height: 100%; align-items: center; gap: 2px; padding: 0 4px; white-space: nowrap; }\n.face[data-face=\"idle\"] { padding: 0; }\n/* A leaving face steps out of the flow, exactly where it was, and fades while the pill springs to the next one. */\n.face[inert] { position: absolute; top: 0; inset-inline-start: 0; pointer-events: none; }\n.part { display: inline-flex; height: 100%; align-items: center; }\n\n.trigger {\n  display: inline-flex;\n  height: 100%;\n  align-items: center;\n  gap: 8px;\n  padding: 0 16px 0 14px;\n  border: 0;\n  border-radius: var(--radius-pill);\n  background: transparent;\n  color: var(--foreground);\n  cursor: pointer;\n  font: inherit;\n  font-weight: 500;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);\n}\n.trigger:active:not(:disabled) { background: color-mix(in oklab, var(--foreground) 9%, transparent); transition-duration: 0ms; }\n.trigger:disabled { color: var(--text-muted); cursor: not-allowed; }\n.trigger:disabled .icon { opacity: .7; }\n.icon { display: grid; width: 16px; height: 16px; place-items: center; color: var(--text-secondary); }\n.trigger:disabled .icon { color: inherit; }\n.icon > svg { width: 16px; height: 16px; }\n\n.prompt { max-width: 16rem; overflow: hidden; padding: 0 8px 0 12px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }\n.prompt > span { overflow: hidden; text-overflow: ellipsis; }\n\n.secondary,\n.confirm {\n  position: relative;\n  display: inline-flex;\n  flex: none;\n  height: calc(var(--control-height-sm) - 8px);\n  align-items: center;\n  justify-content: center;\n  padding: 0 12px;\n  border: 0;\n  border-radius: var(--radius-pill);\n  cursor: pointer;\n  font: inherit;\n  font-weight: 500;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);\n  touch-action: manipulation;\n}\n.secondary { background: transparent; color: var(--text-secondary); }\n.face:is([data-face=\"done\"], [data-face=\"error\"]) .secondary { color: var(--foreground); }\n.secondary:active { background: color-mix(in oklab, var(--foreground) 11%, transparent); color: var(--foreground); transition-duration: 0ms; }\n\n/* The only coloured thing in the control: danger for a destructive answer, the accent for a reversible one. */\n.confirm { overflow: clip; background: color-mix(in oklab, var(--danger) 13%, transparent); color: var(--danger); }\n.confirm:active { background: color-mix(in oklab, var(--danger) 22%, transparent); transition-duration: 0ms; }\n.root[data-tone=\"neutral\"] .confirm { background: var(--accent); color: var(--accent-foreground); }\n.root[data-tone=\"neutral\"] .confirm:active { background: var(--accent-strong); }\n\n/* Hold: a solid copy of the button, label and all, revealed from the left as the hold fills; the label inverts as the edge passes. */\n.root[data-mode=\"hold\"] .confirm { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }\n.fill { position: absolute; inset: 0; display: inline-flex; align-items: center; justify-content: center; background: var(--danger); color: var(--background); pointer-events: none; }\n.root[data-tone=\"neutral\"] .fill { background: var(--accent-strong); color: var(--accent-foreground); }\n\n.status { gap: 8px; padding: 0 10px 0 12px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }\n.status > svg { flex: none; }\n.statusText { display: inline-flex; align-items: baseline; gap: .25em; }\n.face[data-face=\"pending\"] .status { padding-inline: 12px 16px; color: var(--text-secondary); }\n.face[data-face=\"done\"]:not(:has(.secondary)) .status { padding-right: 14px; }\n.face[data-face=\"counting\"] .status { padding-right: 6px; color: var(--text-secondary); }\n.alert { color: var(--danger); }\n.check { width: 18px; height: 18px; }\n.checkDisc { fill: var(--success); }\n.checkTick { stroke: var(--background); }\n.spinner { animation: spin .7s linear infinite; }\n@keyframes spin { to { transform: rotate(360deg); } }\n\n.ring { width: 16px; height: 16px; }\n.ringTrack { stroke: var(--border); }\n.ringFill { stroke: var(--danger); }\n.root[data-tone=\"neutral\"] .ringFill { stroke: var(--accent); }\n/* Seconds keep one tabular width while the digits swap. */\n.seconds { display: inline-grid; min-width: 1ch; color: var(--foreground); font-variant-numeric: tabular-nums; }\n.digit { grid-area: 1 / 1; }\n\n/* No focus rings anywhere in the control; keyboard focus reads through fill (below). */\n.root, .root :focus { outline: none; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }\n\n@media (hover: hover) and (pointer: fine) {\n  .trigger:hover:not(:disabled):not(:active) { background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n  .secondary:hover:not(:active) { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }\n  .confirm:hover:not(:active) { background: color-mix(in oklab, var(--danger) 18%, transparent); }\n  .root[data-tone=\"neutral\"] .confirm:hover:not(:active) { background: var(--accent-strong); }\n}\n/* Keyboard focus reads through fill, never a ring. */\n.trigger:focus-visible { background: color-mix(in oklab, var(--foreground) 6%, transparent); }\n.secondary:focus-visible { background: color-mix(in oklab, var(--foreground) 8%, transparent); color: var(--foreground); }\n.confirm:focus-visible { background: color-mix(in oklab, var(--danger) 20%, transparent); }\n.root[data-tone=\"neutral\"] .confirm:focus-visible { background: var(--accent-strong); }\n\n@media (prefers-reduced-motion: reduce) {\n  .surface, .surface::after, .trigger, .secondary, .confirm { transition-duration: 0ms; }\n  .spinner { animation-duration: 1.6s; }\n}\n\n@media (prefers-contrast: more) {\n  .surface::after { border-color: var(--border-strong); }\n  .confirm { background: color-mix(in oklab, var(--danger) 22%, transparent); }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/confirm-morph",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "actions",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/confirm-morph",
    "markdown": "https://ui.xzero.co.uk/components/confirm-morph/markdown"
  }
}