{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "action-button",
  "type": "registry:ui",
  "title": "Action button",
  "description": "A compact button for frequent toolbar actions.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/action-button/action-button.tsx",
      "type": "registry:component",
      "target": "@components/xzero/action-button/action-button.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ButtonHTMLAttributes, RefObject } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition, Variants } from \"motion/react\";\nimport { ArrowRight } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./action-button.module.css\";\n\nexport interface ActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"> {\n  label: string;\n  successLabel?: string;\n  pendingLabel?: string;\n  onAction: () => void | Promise<void>;\n  resetAfterMs?: number;\n  onActionError?: (error: unknown) => void;\n}\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 rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst labelIn = { duration: .16, ease: [...motionTokens.ease.enter] } as const;\nconst labelOut: TargetAndTransition = { opacity: 0, transition: { duration: .1, ease: [...motionTokens.ease.standard] } };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\n/** The arrow leaves in the direction of the action and returns from behind once the button resets. */\nconst arrowIn: TargetAndTransition = { opacity: 0, x: -6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst arrowOut: TargetAndTransition = { opacity: 0, x: 8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\nconst iconRest: TargetAndTransition = { ...rest, x: 0 };\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */\nconst iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;\n\n/** Springs the wrapper to the natural width of its content when the text changes, so new text never snaps the layout.\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, slot = node?.parentElement;\n    if (!node || !slot || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.contentRect.width;\n      if (!next || !measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; width.jump(next || \"auto\"); delete slot.dataset.morphing; return; }\n      slot.dataset.morphing = \"\";\n      animate(width, next, { type: \"spring\", visualDuration: .24, bounce: 0, onComplete: () => { delete slot.dataset.morphing; } });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [content, reduced, width]);\n  return width;\n}\n\n/** The success tick draws itself from its short stroke, the way a hand would write it. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return <svg className={styles.statusIcon} width={17} height={17} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <motion.path d=\"M4 12l5 5L20 6\" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: .05 }, opacity: { duration: .05, delay: .05 } }} />\n  </svg>;\n}\n\n/** Swaps one label for the next with a single crossfade: the old text fades out fast, the new one fades in, and the width follows without overshoot. */\nfunction SwapText({ text, reduced }: { text: string; reduced: boolean }) {\n  const sizerRef = useRef<HTMLSpanElement>(null);\n  const width = useMorphWidth(sizerRef, text, reduced);\n  return <motion.span className={styles.morph} style={{ width }} aria-hidden=\"true\">\n    <span ref={sizerRef} className={styles.sizer}>{text}</span>\n    <AnimatePresence initial={false}>\n      <motion.span key={text} className={styles.label} initial={reduced ? false : { opacity: 0, y: 2 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : labelOut} transition={labelIn}>{text}</motion.span>\n    </AnimatePresence>\n  </motion.span>;\n}\n\nexport function ActionButton({ label, successLabel = \"Saved\", pendingLabel = \"Saving\", onAction, resetAfterMs = 2400, onActionError, className, disabled, ...props }: ActionButtonProps) {\n  const [state, setState] = useState<\"idle\" | \"pending\" | \"success\">(\"idle\");\n  const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const reduceMotion = useReducedMotion() ?? false;\n  const text = state === \"pending\" ? pendingLabel : state === \"success\" ? successLabel : label;\n\n  useEffect(() => () => { if (resetTimer.current) clearTimeout(resetTimer.current); }, []);\n\n  async function run() {\n    if (state === \"pending\") return;\n    if (resetTimer.current) clearTimeout(resetTimer.current);\n    setState(\"pending\");\n    try {\n      await onAction();\n      setState(\"success\");\n      if (resetAfterMs > 0) resetTimer.current = setTimeout(() => setState(\"idle\"), resetAfterMs);\n    } catch (error) {\n      setState(\"idle\");\n      onActionError?.(error);\n    }\n  }\n\n  const pending = state === \"pending\";\n  const arrow = state === \"idle\";\n\n  // Pending stays focusable (aria-disabled instead of disabled), so a keyboard user keeps focus through the whole save.\n  return <motion.button {...props} ref={buttonRef} tabIndex={props.tabIndex ?? 0} type={props.type ?? \"button\"} className={[styles.button, className].filter(Boolean).join(\" \")} disabled={disabled} aria-disabled={pending ? true : props[\"aria-disabled\"]} aria-busy={pending} data-state={state} onClick={run} custom={buttonRef} variants={pressVariants} whileTap={reduceMotion || disabled || pending ? undefined : \"pressed\"} transition={motionTokens.spring.snappy}>\n    <span className={styles.content} aria-hidden=\"true\">\n      <SwapText text={text} reduced={reduceMotion} />\n      <span className={styles.iconSlot}><AnimatePresence initial={false}><motion.span key={state} className={styles.phase} initial={reduceMotion ? fadeIn : arrow ? arrowIn : iconIn} animate={iconRest} exit={reduceMotion ? fadeOut : arrow ? arrowOut : iconOut} transition={reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter}>{pending ? <span className={styles.spinner} /> : state === \"success\" ? <DrawnCheck reduced={reduceMotion} /> : <ArrowRight className={styles.arrow} width={17} height={17} />}</motion.span></AnimatePresence></span>\n    </span>\n    <span className={styles.visuallyHidden}>{label}</span>\n    <span className={styles.visuallyHidden} role=\"status\">{state === \"pending\" ? pendingLabel : state === \"success\" ? successLabel : \"\"}</span>\n  </motion.button>;\n}\n\nexport default ActionButton;\n"
    },
    {
      "path": "registry/components/action-button/action-button.module.css",
      "type": "registry:component",
      "target": "@components/xzero/action-button/action-button.module.css",
      "content": "/* The button hugs its label: labels crossfade in place and the width follows on a spring with no overshoot, so no state ever snaps the layout. */\n.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; overflow: hidden; padding: 0 var(--space-5); border: 1px solid var(--foreground); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { box-shadow: var(--shadow-resting); } }\n.button:disabled { cursor: not-allowed; }\n.button:disabled:not([data-state=\"pending\"]) { opacity: .7; }\n.button[data-state=\"pending\"] { cursor: progress; }\n.content { display: inline-flex; min-height: 1.25rem; align-items: center; justify-content: center; gap: var(--space-2); white-space: nowrap; }\n.morph { position: relative; display: inline-grid; min-width: 0; justify-items: start; }\n.morph[data-morphing] { clip-path: inset(-.6em 0 -.6em -.3em); }\n/* The sizer holds the current label's width; the visible labels stack in the same cell while one fades out. */\n.sizer { grid-area: 1 / 1; visibility: hidden; white-space: pre; }\n.label { grid-area: 1 / 1; white-space: pre; }\n.iconSlot { display: grid; width: 17px; height: 17px; flex: 0 0 17px; place-items: center; }\n.phase { grid-area: 1 / 1; display: grid; width: 17px; height: 17px; place-items: center; }\n.arrow { flex: none; }\n.statusIcon { color: currentColor; }\n.spinner { display: inline-block; width: var(--space-4); height: var(--space-4); border: 1.5px solid currentColor; border-right-color: transparent; border-radius: var(--radius-pill); animation: spin .7s linear infinite; }\n.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@keyframes spin { to { transform: rotate(360deg); } }\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-button",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "action",
      "toolbar"
    ],
    "docs": "https://ui.xzero.co.uk/components/action-button",
    "markdown": "https://ui.xzero.co.uk/components/action-button/markdown"
  }
}