{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stateful-button",
  "type": "registry:ui",
  "title": "Stateful Submit Button",
  "description": "A submit button that morphs through loading, success and error with a spring-driven width and drawn icons.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/stateful-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nexport type StatefulButtonStatus = \"idle\" | \"loading\" | \"success\" | \"error\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface StatefulButtonProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"button\">,\n    \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\" | \"children\"\n  > {\n  /** Idle label (also the accessible name while idle). */\n  children?: React.ReactNode;\n  /** Controlled status. When set, the button only renders what you pass. */\n  status?: StatefulButtonStatus;\n  /**\n   * Uncontrolled mode: called on click. A resolved promise shows success, a rejected one shows\n   * error, then the button returns to idle after `resetDelay`.\n   */\n  onAction?: () => Promise<unknown> | unknown;\n  /** Called whenever the status changes (both modes). */\n  onStatusChange?: (status: StatefulButtonStatus) => void;\n  /** Called with the rejection reason when `onAction` fails. */\n  onError?: (error: unknown) => void;\n  /** Label shown (and announced) while loading. */\n  loadingLabel?: string;\n  /** Label shown (and announced) on success. */\n  successLabel?: string;\n  /** Label shown (and announced) on error. */\n  errorLabel?: string;\n  /** How long success / error stay before resetting to idle, in ms (uncontrolled mode). */\n  resetDelay?: number;\n  /** Minimum time the loading state is shown, so fast actions do not flash, in ms. */\n  minLoadingTime?: number;\n  /** Fill colour while idle and loading. */\n  accent?: string;\n  /** Fill colour on success. */\n  successColor?: string;\n  /** Fill colour on error. */\n  errorColor?: string;\n  /** Label colour on top of the solid fills. */\n  inkColor?: string;\n  /** Solid fill, or a quiet outline with coloured icons. */\n  variant?: \"solid\" | \"outline\";\n  /** Shrink to a round spinner while loading. */\n  collapse?: boolean;\n  /** Spring that morphs the width between states. */\n  spring?: Spring;\n  /** Optional icon shown before the idle label. */\n  icon?: React.ReactNode;\n}\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\n\nfunction Spinner() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 shrink-0 motion-safe:animate-spin\" aria-hidden=\"true\">\n      <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.75\" />\n      <path d=\"M14 8a6 6 0 0 0-6-6\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/** A check or a cross that draws itself in. */\nfunction DrawnGlyph({ kind, instant }: { kind: \"check\" | \"cross\"; instant: boolean }) {\n  const paths = kind === \"check\" ? [\"M3.5 8.5l3 3 6-7\"] : [\"M4.75 4.75l6.5 6.5\", \"M11.25 4.75l-6.5 6.5\"];\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 shrink-0\" aria-hidden=\"true\">\n      {paths.map((d, i) => (\n        <motion.path\n          key={d}\n          d={d}\n          stroke=\"currentColor\"\n          strokeWidth=\"1.8\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={{ pathLength: instant ? 1 : 0 }}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: 0.3, delay: instant ? 0 : 0.08 + i * 0.12, ease: EASE }}\n        />\n      ))}\n    </svg>\n  );\n}\n\n/**\n * A submit button that morphs through idle → loading → success | error. Width, label and icon\n * animate with a shared layout spring. Use `status` to control it, or pass an async `onAction`\n * and it manages the whole cycle (including the reset) itself.\n */\nexport const StatefulButton = React.forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    children = \"Save changes\",\n    status: statusProp,\n    onAction,\n    onStatusChange,\n    onError,\n    loadingLabel = \"Saving…\",\n    successLabel = \"Saved\",\n    errorLabel = \"Try again\",\n    resetDelay = 2000,\n    minLoadingTime = 400,\n    accent = \"#FF7448\",\n    successColor = \"#B6F3D1\",\n    errorColor = \"#F87171\",\n    inkColor = \"#0B0D0F\",\n    variant = \"solid\",\n    collapse = false,\n    spring = { stiffness: 420, damping: 32, mass: 0.8 },\n    icon,\n    className = \"\",\n    style,\n    onClick,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = useReducedMotion();\n  const [internal, setInternal] = React.useState<StatefulButtonStatus>(\"idle\");\n  const status = statusProp ?? internal;\n\n  // Each action gets an id so late results from an earlier click (or after unmount) are ignored.\n  const runId = React.useRef(0);\n  const resetTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  React.useEffect(\n    () => () => {\n      clearTimeout(resetTimer.current);\n      runId.current += 1;\n    },\n    [],\n  );\n\n  const setStatus = (next: StatefulButtonStatus) => {\n    if (statusProp === undefined) setInternal(next);\n    onStatusChange?.(next);\n  };\n\n  const run = async () => {\n    if (!onAction) return;\n    const id = ++runId.current;\n    clearTimeout(resetTimer.current);\n    setStatus(\"loading\");\n    const started = performance.now();\n    let next: StatefulButtonStatus = \"success\";\n    try {\n      await onAction();\n    } catch (error) {\n      next = \"error\";\n      onError?.(error);\n    }\n    const remaining = minLoadingTime - (performance.now() - started);\n    if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining));\n    if (id !== runId.current) return;\n    setStatus(next);\n    resetTimer.current = setTimeout(() => {\n      if (id === runId.current) setStatus(\"idle\");\n    }, Math.max(resetDelay, 0));\n  };\n\n  const busy = status === \"loading\";\n  const collapsed = busy && collapse;\n\n  const fill = status === \"success\" ? successColor : status === \"error\" ? errorColor : accent;\n  const announcement =\n    status === \"loading\" ? loadingLabel : status === \"success\" ? successLabel : status === \"error\" ? errorLabel : \"\";\n\n  const layoutTransition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  const surface =\n    variant === \"solid\"\n      ? \"border-transparent bg-[var(--sb-fill)] text-[var(--sb-ink)] hover:brightness-105\"\n      : status === \"idle\"\n        ? \"border-zinc-900/15 bg-transparent text-zinc-900 hover:border-zinc-900/30 dark:border-white/15 dark:text-zinc-100 dark:hover:border-white/30\"\n        : // Mixing with the text colour keeps pale fills (mint) legible on light backgrounds.\n          \"border-[color-mix(in_oklab,var(--sb-fill)_60%,currentColor)] bg-[color-mix(in_oklab,var(--sb-fill)_10%,transparent)] text-zinc-900 dark:text-zinc-100\";\n\n  const iconTone = variant === \"outline\" && status !== \"idle\" ? \"text-[color-mix(in_oklab,var(--sb-fill)_70%,currentColor)]\" : \"\";\n\n  let content: React.ReactNode;\n  if (status === \"loading\") {\n    content = (\n      <>\n        <span className={iconTone}>\n          <Spinner />\n        </span>\n        <span className={collapsed ? \"sr-only\" : undefined}>{loadingLabel}</span>\n      </>\n    );\n  } else if (status === \"success\" || status === \"error\") {\n    content = (\n      <>\n        <span className={iconTone}>\n          <DrawnGlyph kind={status === \"success\" ? \"check\" : \"cross\"} instant={!!reduceMotion} />\n        </span>\n        <span>{status === \"success\" ? successLabel : errorLabel}</span>\n      </>\n    );\n  } else {\n    content = (\n      <>\n        {icon && (\n          <span aria-hidden=\"true\" className=\"inline-flex shrink-0 [&_svg]:size-4\">\n            {icon}\n          </span>\n        )}\n        <span>{children}</span>\n      </>\n    );\n  }\n\n  return (\n    <span className=\"relative inline-flex\">\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        layout\n        transition={{ layout: layoutTransition }}\n        aria-busy={busy || undefined}\n        // aria-disabled (not `disabled`) keeps focus on the button while it is busy.\n        aria-disabled={busy || undefined}\n        data-status={status}\n        onClick={(event) => {\n          if (busy) {\n            event.preventDefault();\n            return;\n          }\n          onClick?.(event);\n          if (event.defaultPrevented || status === \"success\") return;\n          void run();\n        }}\n        className={`relative inline-flex h-11 select-none items-center justify-center overflow-hidden whitespace-nowrap border text-sm font-medium tracking-tight transition-[background-color,border-color,color,filter] duration-300 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--sb-accent)] disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress ${\n          collapsed ? \"w-11 px-0\" : \"px-5\"\n        } ${surface} ${className}`}\n        style={{\n          borderRadius: 999,\n          [\"--sb-accent\" as string]: accent,\n          [\"--sb-fill\" as string]: fill,\n          [\"--sb-ink\" as string]: inkColor,\n          ...style,\n        }}\n        {...props}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={status}\n            layout=\"position\"\n            className=\"inline-flex items-center gap-2\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 10, filter: \"blur(2px)\" }}\n            animate={\n              reduceMotion\n                ? { opacity: 1 }\n                : status === \"error\"\n                  ? { opacity: 1, y: 0, filter: \"blur(0px)\", x: [0, -3, 3, -2, 0] }\n                  : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n            }\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -10, filter: \"blur(2px)\" }}\n            transition={{ duration: reduceMotion ? 0.12 : 0.26, ease: EASE, x: { duration: 0.36, delay: 0.1 } }}\n          >\n            {content}\n          </motion.span>\n        </AnimatePresence>\n      </motion.button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </span>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/stateful-button"
}
