{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "action-swap",
  "type": "registry:ui",
  "title": "Action swap",
  "description": "Swap an action label while preserving its place and intent.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/action-swap/action-swap.tsx",
      "type": "registry:component",
      "target": "@components/xzero/action-swap/action-swap.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, type RefObject } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionStyle, type TargetAndTransition, type Transition, type Variants } from \"motion/react\";\nimport { CircleAlert } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./action-swap.module.css\";\n\nexport type ActionSwapTone = \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"quiet\";\n\nexport interface ActionSwapState {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  tone?: ActionSwapTone;\n  /** Shown while `onAction` runs on the way into this state, such as \"Sending\" before \"Sent\". Without it the pill folds into a round spinner. */\n  pendingLabel?: string;\n  /** Milliseconds to hold this state before moving on by itself, for brief confirmations such as \"Sent\". */\n  holdFor?: number;\n}\n\nexport interface ActionSwapProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"onChange\" | \"onClick\" | \"value\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"> {\n  states: ActionSwapState[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, state: ActionSwapState) => void;\n  /** Runs on press before the control moves on. Return a promise to show the working phase; a rejection shows `failureLabel`, then returns to the current state. */\n  onAction?: (next: ActionSwapState, current: ActionSwapState) => void | PromiseLike<unknown>;\n  failureLabel?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n\ntype Phase = { kind: \"idle\" } | { kind: \"working\"; target: ActionSwapState } | { kind: \"failed\" };\nconst IDLE: Phase = { kind: \"idle\" };\nconst FAILURE_HOLD = 1800;\n/** A fast response still shows the working shape long enough to read as a step, never as a flash. */\nconst MIN_WORKING = 450;\nconst ICON = 16;\n\nconst pressVariants: Variants = {\n  pressed: (button: RefObject<HTMLButtonElement | null>) => ({ scale: (button.current?.offsetWidth ?? 0) > 220 ? .985 : .97, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),\n};\nconst morph = motionTokens.spring.morph;\nconst instant = { duration: motionTokens.duration.instant } as const;\nconst soft = `blur(${motionTokens.blur.soft}px)`;\nconst tween = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } as const;\nconst leave = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } as const;\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: instant };\n/** Letters rise about .3em out of a soft blur; the outgoing ones lift away a little faster. */\nconst glyphIn: TargetAndTransition = { opacity: 0, y: 4, filter: soft };\nconst glyphOut: TargetAndTransition = { opacity: 0, y: -3, filter: soft, transition: leave };\n/** Icons trade places through a blur: the old one shrinks away while the new one grows out of it on the spring. */\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .5, filter: soft };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: leave };\nconst iconEnter: Transition = { scale: motionTokens.spring.snappy, opacity: tween, filter: tween };\n/** Success lands with a little more give: the icon settles just past full size once while its stroke wipes in from the leading edge. */\nconst successIn: TargetAndTransition = { ...iconIn, clipPath: \"inset(0% 100% 0% 0%)\" };\nconst successRest: TargetAndTransition = { ...rest, clipPath: \"inset(0% 0% 0% 0%)\" };\nconst successEnter: Transition = { scale: { type: \"spring\", visualDuration: .45, bounce: 0 }, opacity: tween, filter: tween, clipPath: { duration: .36, ease: [...motionTokens.ease.standard], delay: .02 } };\n\n/** Springs the wrapper to the border-box width of the incoming label when the text changes, down to zero when the pill folds.\n *  Other resizes (a late web font, a parent reflow) jump straight to the new width, so nothing wobbles on first paint. */\nfunction useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(key), armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    armedUntil.current = performance.now() + 700;\n  }, [key]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      if (!measured || reduced || performance.now() > armedUntil.current) { measured = true; width.jump(next); return; }\n      animate(width, next, morph);\n    });\n    observer.observe(node, { box: \"border-box\" });\n    return () => observer.disconnect();\n  }, [content, reduced, width]);\n  // The spring may dip below zero as it settles on a fold; a negative width would be dropped by the browser and pop the label back.\n  return useTransform(width, value => typeof value === \"number\" ? Math.max(0, value) : value);\n}\n\ntype Glyph = { id: string; char: string; order: number; lead: number };\nconst toGlyphs = (chars: string[], seq: number, lead = 0): Glyph[] => chars.map((char, order) => ({ id: `${seq}:${order}`, char, order, lead }));\n\n/** Shared leading and trailing characters keep their identity, so only the changed run of text is replaced.\n *  Letters that grow out of a folded pill wait a beat, so they arrive as the shape opens instead of crowding its edge. */\nfunction useGlyphs(text: string) {\n  const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));\n  if (state.text === text) return state.glyphs;\n  const prev = [...state.text], next = [...text];\n  let start = 0, end = 0;\n  while (start < prev.length && start < next.length && prev[start] === next[start]) start++;\n  while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end]) end++;\n  if (start < 2) start = 0;\n  if (end < 2) end = 0;\n  const seq = state.seq + 1;\n  const glyphs = [...state.glyphs.slice(0, start), ...toGlyphs(next.slice(start, next.length - end), seq, prev.length ? 0 : .1), ...state.glyphs.slice(state.glyphs.length - end)];\n  setState({ text, seq, glyphs });\n  return glyphs;\n}\n\n/** Morphs one label into the next: kept letters stay, new ones rise in from a soft blur, and the width follows on a spring.\n *  The gap to the icon lives inside the measured row, so it closes with the label when the pill folds. */\nfunction MorphText({ text, gap, reduced }: { text: string; gap: boolean; reduced: boolean }) {\n  const glyphs = useGlyphs(text);\n  const rowRef = useRef<HTMLSpanElement>(null);\n  const layoutKey = `${gap ? 1 : 0}${text}`;\n  const width = useMorphWidth(rowRef, layoutKey, reduced);\n  return <motion.span className={styles.morph} style={{ width }} aria-hidden=\"true\">\n    <span ref={rowRef} className={styles.glyphs} data-gap={gap || undefined}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {glyphs.map(glyph => <motion.span key={glyph.id} className={styles.glyph} layout={reduced ? false : \"position\"} layoutDependency={layoutKey} initial={reduced ? fadeIn : glyphIn} animate={rest} exit={reduced ? fadeOut : glyphOut} transition={reduced ? instant : { ...tween, delay: glyph.lead + Math.min(glyph.order * motionTokens.stagger.char, .12), layout: morph }}>{glyph.char}</motion.span>)}\n      </AnimatePresence>\n    </span>\n  </motion.span>;\n}\n\n/** A compact action that morphs through a small set of related states: idle, working, done, and back.\n *  Width, padding, tone, icon, and letters move together on one spring, and every change can be interrupted mid-flight. */\nexport function ActionSwap({ states, value, defaultValue, onValueChange, onAction, failureLabel = \"Try again\", size = \"md\", className, disabled, style, ...props }: ActionSwapProps) {\n  const firstId = states[0]?.id ?? \"\";\n  const [internalValue, setInternalValue] = useState(defaultValue ?? firstId);\n  const [phase, setPhase] = useState<Phase>(IDLE);\n  const [spoken, setSpoken] = useState(\"\");\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const run = useRef(0);\n  const reduced = useReducedMotion() ?? false;\n  const activeValue = value ?? internalValue;\n  const activeIndex = Math.max(0, states.findIndex(state => state.id === activeValue));\n  const activeState = states[activeIndex] ?? states[0];\n  const nextState = states[(activeIndex + 1) % states.length] ?? activeState;\n  const inactive = disabled || states.length < 2;\n  const working = phase.kind === \"working\";\n  const folded = phase.kind === \"working\" && !phase.target.pendingLabel;\n\n  function commit(next: ActionSwapState) {\n    setPhase(IDLE);\n    if (value === undefined) setInternalValue(next.id);\n    setSpoken(next.label);\n    onValueChange?.(next.id, next);\n  }\n  function fail() {\n    setPhase({ kind: \"failed\" });\n    setSpoken(failureLabel);\n  }\n  function advance() {\n    if (!activeState || !nextState || inactive || working) return;\n    const next = nextState, id = ++run.current;\n    let result: void | PromiseLike<unknown>;\n    try { result = onAction?.(next, activeState); } catch { fail(); return; }\n    if (!result || typeof result.then !== \"function\") { commit(next); return; }\n    setPhase({ kind: \"working\", target: next });\n    setSpoken(next.pendingLabel ?? \"Working\");\n    const started = performance.now();\n    const settle = (done: () => void) => window.setTimeout(() => { if (run.current === id) done(); }, Math.max(0, MIN_WORKING - (performance.now() - started)));\n    result.then(() => settle(() => commit(next)), () => settle(fail));\n  }\n\n  // Timers read the latest props through a ref, so a parent re-render never restarts a hold.\n  const latest = useRef({ commit, nextState });\n  useLayoutEffect(() => { latest.current = { commit, nextState }; });\n  useEffect(() => () => { run.current += 1; }, []);\n  const holdFor = phase.kind === \"idle\" ? activeState?.holdFor : undefined;\n  useEffect(() => {\n    if (!holdFor) return;\n    const timer = window.setTimeout(() => { const { commit: settle, nextState: next } = latest.current; if (next) settle(next); }, holdFor);\n    return () => window.clearTimeout(timer);\n  }, [holdFor, activeState?.id]);\n  useEffect(() => {\n    if (phase.kind !== \"failed\") return;\n    const timer = window.setTimeout(() => setPhase(IDLE), FAILURE_HOLD);\n    return () => window.clearTimeout(timer);\n  }, [phase]);\n\n  // One value drives the fold: the padding eases from the pill's to the circle's on the same spring as the label width.\n  const fold = useMotionValue(folded ? 1 : 0);\n  useEffect(() => { animate(fold, folded ? 1 : 0, reduced ? { duration: 0 } : morph); }, [fold, folded, reduced]);\n\n  if (!activeState) return null;\n\n  const view = phase.kind === \"working\" ? { key: \"working\", label: phase.target.pendingLabel ?? \"\", icon: <span className={styles.spinner} />, tone: activeState.tone ?? \"neutral\" }\n    : phase.kind === \"failed\" ? { key: \"failed\", label: failureLabel, icon: <CircleAlert size={ICON} strokeWidth={1.75} />, tone: \"danger\" }\n    : { key: `state:${activeState.id}`, label: activeState.label, icon: activeState.icon, tone: activeState.tone ?? \"neutral\" };\n  const draw = view.tone === \"success\" && !reduced;\n\n  // tabIndex is pinned so the server and a reduced-motion client (which drops whileTap, and with it Motion's implicit tabindex) render alike.\n  return <>\n    <motion.button {...props} ref={buttonRef} type={props.type ?? \"button\"} tabIndex={props.tabIndex ?? 0} className={[styles.button, styles[size], styles[view.tone], className].filter(Boolean).join(\" \")} style={{ ...style, \"--fold\": fold } as MotionStyle} disabled={inactive} aria-disabled={working || undefined} aria-busy={working || undefined} aria-label={view.label || activeState.label} data-phase={phase.kind} onClick={advance} custom={buttonRef} variants={pressVariants} whileTap={reduced || inactive || working ? undefined : \"pressed\"} transition={motionTokens.spring.snappy}>\n      <motion.span className={styles.icon} aria-hidden=\"true\" initial={false} animate={{ width: view.icon ? ICON : 0 }} transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}>\n        <AnimatePresence initial={false}>\n          {view.icon ? <motion.span key={view.key} className={styles.iconPhase} data-tone={view.tone} initial={reduced ? fadeIn : draw ? successIn : iconIn} animate={draw ? successRest : rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? instant : draw ? successEnter : iconEnter}>{view.icon}</motion.span> : null}\n        </AnimatePresence>\n      </motion.span>\n      <MorphText text={view.label} gap={Boolean(view.icon) && view.label.length > 0} reduced={reduced} />\n    </motion.button>\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{spoken}</span>\n  </>;\n}\n\nexport default ActionSwap;\n"
    },
    {
      "path": "registry/components/action-swap/action-swap.module.css",
      "type": "registry:component",
      "target": "@components/xzero/action-swap/action-swap.module.css",
      "content": "/* The pill hugs its content: tone, icon, and letters morph together while width and padding follow on one spring.\n   While an action runs without a pending label, --fold (0 to 1) eases the padding down until the pill is a circle around its spinner.\n   The pill clips its own content, so letters that run ahead of the width are revealed by the growing shape rather than a hard inner edge. */\n.button { --swap-height: var(--control-height-md); --swap-pad: var(--space-5); position: relative; display: inline-flex; min-width: var(--swap-height); min-height: var(--swap-height); align-items: center; justify-content: center; padding: 0 calc(var(--swap-pad) + ((var(--swap-height) - 18px) / 2 - var(--swap-pad)) * var(--fold, 0)); border: 1px solid var(--foreground); border-radius: var(--radius-pill); background: var(--foreground); color: var(--background); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); overflow: clip; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n.sm { --swap-height: var(--control-height-sm); --swap-pad: var(--space-4); }\n.md { --swap-height: var(--control-height-md); --swap-pad: var(--space-5); }\n.lg { --swap-height: var(--control-height-lg); --swap-pad: var(--space-6); }\n.button:disabled { cursor: default; opacity: .6; }\n.button[aria-busy=\"true\"] { cursor: progress; }\n/* Filled tones carry the primary action; tinted tones confirm a result with most of the color on the icon. */\n.neutral { border-color: var(--foreground); background: var(--foreground); color: var(--background); }\n.accent { border-color: var(--accent); background: var(--accent); color: var(--accent-foreground); }\n.quiet { border-color: var(--border); background: var(--surface); color: var(--foreground); }\n.success { border-color: color-mix(in oklab, var(--success) 25%, var(--border)); background: color-mix(in oklab, var(--success) 10%, var(--surface)); color: var(--foreground); }\n.warning { border-color: color-mix(in oklab, var(--warning) 30%, var(--border)); background: color-mix(in oklab, var(--warning) 12%, var(--surface)); color: var(--foreground); }\n.danger { border-color: color-mix(in oklab, var(--danger) 25%, var(--border)); background: color-mix(in oklab, var(--danger) 10%, var(--surface)); color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) {\n  .neutral:hover:not(:disabled, [aria-busy=\"true\"]) { border-color: var(--text-secondary); background: var(--text-secondary); }\n  .accent:hover:not(:disabled, [aria-busy=\"true\"]) { border-color: var(--accent-strong); background: var(--accent-strong); }\n  .quiet:hover:not(:disabled, [aria-busy=\"true\"]) { border-color: var(--border-strong); background: var(--surface-muted); }\n  .success:hover:not(:disabled) { border-color: color-mix(in oklab, var(--success) 42%, var(--border)); }\n  .warning:hover:not(:disabled) { border-color: color-mix(in oklab, var(--warning) 48%, var(--border)); }\n  .danger:hover:not(:disabled) { border-color: color-mix(in oklab, var(--danger) 42%, var(--border)); }\n}\n.icon { position: relative; display: grid; height: 16px; flex: none; place-items: center; }\n.iconPhase { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; }\n.iconPhase svg { flex: none; }\n.iconPhase[data-tone=\"success\"] { color: var(--success); }\n.iconPhase[data-tone=\"warning\"] { color: var(--warning); }\n.iconPhase[data-tone=\"danger\"] { color: var(--danger); }\n/* A quiet ring: a faint full track with one bright arc turning around it. */\n.spinner { display: block; width: 14px; height: 14px; border: 1.5px solid color-mix(in oklab, currentColor 24%, transparent); border-top-color: currentColor; border-radius: 50%; animation: swap-spin .75s linear infinite; }\n@keyframes swap-spin { to { transform: rotate(360deg); } }\n.morph { display: inline-flex; min-width: 0; }\n.glyphs { position: relative; display: inline-flex; flex: none; white-space: pre; }\n.glyphs[data-gap] { padding-left: var(--space-2); }\n.glyph { display: inline-block; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } .spinner { animation: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/action-swap",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "action",
      "motion"
    ],
    "docs": "https://ui.xzero.co.uk/components/action-swap",
    "markdown": "https://ui.xzero.co.uk/components/action-swap/markdown"
  }
}