{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\n\nexport interface ActionButtonProps extends Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"onClick\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"\n> {\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>) => ({\n    scale: (button.current?.offsetWidth ?? 0) > 220 ? 0.985 : 0.97,\n    transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n  }),\n};\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst labelIn = { duration: 0.16, ease: [...motionTokens.ease.enter] } as const;\nconst labelOut: TargetAndTransition = {\n  opacity: 0,\n  transition: { duration: 0.1, ease: [...motionTokens.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: [...motionTokens.ease.standard] },\n};\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 = {\n  opacity: 0,\n  x: 8,\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\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 = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n  filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n} 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),\n    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      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) {\n        measured = next > 0;\n        width.jump(next || \"auto\");\n        delete slot.dataset.morphing;\n        return;\n      }\n      slot.dataset.morphing = \"\";\n      animate(width, next, {\n        type: \"spring\",\n        visualDuration: 0.24,\n        bounce: 0,\n        onComplete: () => {\n          delete slot.dataset.morphing;\n        },\n      });\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 (\n    <svg\n      className=\"text-current\"\n      width={17}\n      height={17}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <motion.path\n        d=\"M4 12l5 5L20 6\"\n        initial={reduced ? false : { pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        transition={{\n          pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: 0.05 },\n          opacity: { duration: 0.05, delay: 0.05 },\n        }}\n      />\n    </svg>\n  );\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 (\n    <motion.span\n      className=\"relative inline-grid min-w-0 justify-items-start data-morphing:[clip-path:inset(-.6em_0_-.6em_-.3em)]\"\n      style={{ width }}\n      aria-hidden=\"true\"\n    >\n      <span ref={sizerRef} className=\"invisible [grid-area:1/1] whitespace-pre\">\n        {text}\n      </span>\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={text}\n          className=\"[grid-area:1/1] whitespace-pre\"\n          initial={reduced ? false : { opacity: 0, y: 2 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={reduced ? { opacity: 0, transition: { duration: 0 } } : labelOut}\n          transition={labelIn}\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nexport function ActionButton({\n  label,\n  successLabel = \"Saved\",\n  pendingLabel = \"Saving\",\n  onAction,\n  resetAfterMs = 2400,\n  onActionError,\n  className,\n  disabled,\n  ...props\n}: 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(\n    () => () => {\n      if (resetTimer.current) clearTimeout(resetTimer.current);\n    },\n    [],\n  );\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 (\n    <motion.button\n      {...props}\n      ref={buttonRef}\n      tabIndex={props.tabIndex ?? 0}\n      type={props.type ?? \"button\"}\n      className={cn(\n        \"relative inline-flex min-h-control-md cursor-pointer items-center justify-center overflow-hidden rounded-button border border-ink bg-ink px-5 text-sm leading-body font-medium text-page [-webkit-tap-highlight-color:transparent] transition-[background,border-color,color,box-shadow,opacity] duration-fast ease-standard enabled:hover:shadow-resting disabled:cursor-not-allowed disabled:not-data-[state=pending]:opacity-70 data-[state=pending]:cursor-progress motion-reduce:transition-none\",\n        className,\n      )}\n      disabled={disabled}\n      aria-disabled={pending ? true : props[\"aria-disabled\"]}\n      aria-busy={pending}\n      data-state={state}\n      onClick={run}\n      custom={buttonRef}\n      variants={pressVariants}\n      whileTap={reduceMotion || disabled || pending ? undefined : \"pressed\"}\n      transition={motionTokens.spring.snappy}\n    >\n      <span className=\"inline-flex min-h-5 items-center justify-center gap-2 whitespace-nowrap\" aria-hidden=\"true\">\n        <SwapText text={text} reduced={reduceMotion} />\n        <span className=\"grid size-[17px] flex-[0_0_17px] place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={state}\n              className=\"[grid-area:1/1] grid size-[17px] place-items-center\"\n              initial={reduceMotion ? fadeIn : arrow ? arrowIn : iconIn}\n              animate={iconRest}\n              exit={reduceMotion ? fadeOut : arrow ? arrowOut : iconOut}\n              transition={reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter}\n            >\n              {pending ? (\n                <span className=\"inline-block size-4 animate-[spin_.7s_linear_infinite] rounded-full border-[1.5px] border-current border-r-transparent motion-reduce:animate-none\" />\n              ) : state === \"success\" ? (\n                <DrawnCheck reduced={reduceMotion} />\n              ) : (\n                <ArrowRight className=\"flex-none\" width={17} height={17} />\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </span>\n      <span className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)]\">{label}</span>\n      <output className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)]\" aria-live=\"polite\">\n        {state === \"pending\" ? pendingLabel : state === \"success\" ? successLabel : \"\"}\n      </output>\n    </motion.button>\n  );\n}\n\nexport default ActionButton;\n",
      "type": "registry:component",
      "target": "@components/iris/action-button/action-button.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/action-button",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "ActionButton",
    "tags": [
      "action",
      "toolbar"
    ],
    "docs": "https://irisui.dev/components/action-button"
  }
}
