{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loading-button",
  "type": "registry:ui",
  "title": "Loading Button",
  "description": "An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/loading-button.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Button, type ButtonProps } from \"@/components/ui/button\";\nimport { duration, ease, spring, swap } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type MouseEvent, type ReactNode, useEffect, useRef, useState } from \"react\";\n\n/*\n * A Button that shows the work it starts: idle, loading, success, error (owner, 2026-10-14; Lab only\n * until it's approved, so there's no meta file yet).\n *\n * - State: control it with `state`, or leave it out and return a promise from `onClick`. Loading lasts\n *   until the promise settles, then success or error shows, and the button returns to idle after\n *   `resetAfter`.\n * - Motion (.agents/rules/motion.md): the content swaps through a blur with a small rise (owner,\n *   2026-10-14): the old blurs and lifts away as the new rises in from blurred. The spinner turns; the\n *   check and cross draw themselves in. With reduced motion the content only fades and the spinner pulses.\n * - Width never jumps: every state is laid out invisibly in the same cell, so the button is as wide as its\n *   widest state.\n * - Error is solid red (owner, 2026-10-14), just the text on ghost and link. Success stays neutral, with\n *   the check carrying the meaning.\n * - Busy, it stays focusable (aria-disabled, not disabled) and ignores clicks, and a polite live region\n *   says what happened.\n */\n\nexport type LoadingButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport type LoadingButtonProps = Omit<ButtonProps, \"onClick\" | \"asChild\"> & {\n  /** Control the state yourself. Leave it out and a click that returns a promise drives it. */\n  state?: LoadingButtonState;\n  /** Return a promise to show loading until it settles, then success or error */\n  onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;\n  /** Shown while loading, next to the spinner; the label stays if left out */\n  loadingText?: ReactNode;\n  /** Shown on success, next to the check */\n  successText?: ReactNode;\n  /** Shown on error, next to the cross */\n  errorText?: ReactNode;\n  /** After success or error, milliseconds until it's idle again; `false` to stay. Ignored when controlled. */\n  resetAfter?: number | false;\n};\n\nconst CHECK = \"M5 12.5l4.5 4.5L19 7.5\";\nconst CROSS = \"M7 7l10 10M17 7L7 17\";\n\n/** A ring with a turning arc; with reduced motion it pulses instead */\nfunction Spinner({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden\n      animate={reduce ? { opacity: [1, 0.4, 1] } : { rotate: 360 }}\n      transition={\n        reduce\n          ? { duration: duration.slower * 3, repeat: Number.POSITIVE_INFINITY, ease: ease.inOut }\n          : // One turn a second, at a constant speed\n            { duration: duration.slower * 2, repeat: Number.POSITIVE_INFINITY, ease: \"linear\" }\n      }\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" strokeWidth=\"2.5\" opacity=\"0.25\" />\n      <path d=\"M21 12a9 9 0 0 0-9-9\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" />\n    </motion.svg>\n  );\n}\n\n/** A stroke that draws itself in (the check or the cross); a 16px icon, so drawing it costs nothing */\nfunction Mark({ d, reduce }: { d: string; reduce: boolean }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden>\n      <motion.path\n        d={d}\n        stroke=\"currentColor\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={{ pathLength: reduce ? 1 : 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }}\n      />\n    </svg>\n  );\n}\n\nconst isPromise = (value: unknown): value is PromiseLike<unknown> =>\n  typeof (value as PromiseLike<unknown> | null)?.then === \"function\";\n\n/** What a state shows. Sizers get a blank square for the icon, so nothing animates out of sight. */\nfunction contentOf(\n  state: LoadingButtonState,\n  {\n    children,\n    loadingText,\n    successText,\n    errorText,\n  }: Pick<LoadingButtonProps, \"children\" | \"loadingText\" | \"successText\" | \"errorText\">,\n  icons: { reduce: boolean } | null,\n) {\n  const icon = (live: ReactNode) => (icons ? live : <span className=\"size-(--size-icon-md) shrink-0\" />);\n  switch (state) {\n    case \"loading\":\n      return (\n        <>\n          {icon(<Spinner reduce={icons?.reduce ?? false} />)}\n          {loadingText ?? children}\n        </>\n      );\n    case \"success\":\n      return (\n        <>\n          {icon(<Mark d={CHECK} reduce={icons?.reduce ?? false} />)}\n          {successText ?? children}\n        </>\n      );\n    case \"error\":\n      return (\n        <>\n          {icon(<Mark d={CROSS} reduce={icons?.reduce ?? false} />)}\n          {errorText ?? children}\n        </>\n      );\n    default:\n      return children;\n  }\n}\n\nconst STATES: LoadingButtonState[] = [\"idle\", \"loading\", \"success\", \"error\"];\nconst ANNOUNCEMENTS: Record<LoadingButtonState, string> = {\n  idle: \"\",\n  loading: \"Loading\",\n  success: \"Done\",\n  error: \"Something went wrong\",\n};\n\nfunction LoadingButton({\n  state,\n  onClick,\n  children,\n  loadingText,\n  successText,\n  errorText,\n  resetAfter = 2000,\n  variant,\n  className,\n  ...props\n}: LoadingButtonProps) {\n  const [internal, setInternal] = useState<LoadingButtonState>(\"idle\");\n  const current = state ?? internal;\n  const reduce = useReducedMotion() ?? false;\n  const mounted = useRef(true);\n  const texts = { children, loadingText, successText, errorText };\n\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  // Uncontrolled, success and error give way to idle after a moment\n  useEffect(() => {\n    if (state !== undefined || resetAfter === false) return;\n    if (internal !== \"success\" && internal !== \"error\") return;\n    const timer = setTimeout(() => setInternal(\"idle\"), resetAfter);\n    return () => clearTimeout(timer);\n  }, [internal, state, resetAfter]);\n\n  const busy = current === \"loading\";\n  // Error is solid red (owner, 2026-10-14). Ghost and link keep their surface, so they turn only their text red.\n  const errorClass =\n    variant === \"ghost\" || variant === \"link\"\n      ? \"text-destructive\"\n      : \"border-transparent bg-destructive text-destructive-foreground\";\n  // With reduced motion: a plain fade, no blur or movement\n  const motionProps = reduce\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }\n    : swap;\n\n  return (\n    <>\n      <Button\n        data-slot=\"loading-button\"\n        data-state={current}\n        variant={variant}\n        aria-busy={busy || undefined}\n        aria-disabled={busy || undefined}\n        className={cn(current === \"error\" && errorClass, busy && \"cursor-progress\", className)}\n        onClick={(event) => {\n          if (busy) {\n            event.preventDefault();\n            return;\n          }\n          const result = onClick?.(event);\n          if (state !== undefined || !isPromise(result)) return;\n          setInternal(\"loading\");\n          result.then(\n            () => mounted.current && setInternal(\"success\"),\n            () => mounted.current && setInternal(\"error\"),\n          );\n        }}\n        {...props}\n      >\n        <span className=\"grid\">\n          {/* Every state, invisible, in one cell: the button is as wide as the widest */}\n          {STATES.map((name) => (\n            <span\n              key={name}\n              aria-hidden\n              className=\"invisible col-start-1 row-start-1 flex items-center justify-center gap-2\"\n            >\n              {contentOf(name, texts, null)}\n            </span>\n          ))}\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={current}\n              {...motionProps}\n              transition={\n                reduce\n                  ? { duration: duration.fast }\n                  : { duration: duration.base, ease: ease.out, y: spring.snappy }\n              }\n              className=\"col-start-1 row-start-1 flex items-center justify-center gap-2\"\n            >\n              {contentOf(current, texts, { reduce })}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </Button>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {ANNOUNCEMENTS[current]}\n      </span>\n    </>\n  );\n}\n\nexport { LoadingButton };\n"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/loading-button"
}
