{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-button",
  "title": "Status Button",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Button that shows loading and success feedback for async actions.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "https://chanhdai.com/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "src/registry/components/status-button/status-button.tsx",
      "content": "\"use client\"\n\nimport { useEffect } from \"react\"\nimport { CheckIcon } from \"lucide-react\"\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n  type Variants,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { useControllableState } from \"@/registry/hooks/use-controllable-state\"\n\nexport type ButtonStatus = \"idle\" | \"loading\" | \"success\"\n\nconst DEFAULT_SUCCESS_DURATION = 1500\n\nconst swapVariants: Variants = {\n  initial: { opacity: 0, y: 8, filter: \"blur(4px)\" },\n  animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: { opacity: 0, y: -8, filter: \"blur(4px)\" },\n}\n\nconst reducedMotionVariants: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n}\n\n// A spring keeps its velocity when the status changes mid-swap; a cubic-bezier\n// would restart from zero.\nconst swapTransition: Transition = {\n  type: \"spring\",\n  duration: 0.3,\n  bounce: 0,\n}\n\n// Bounce only on the entrance, and only on movement: opacity and blur would\n// overshoot.\nconst successVariants: Variants = {\n  ...swapVariants,\n  animate: {\n    ...swapVariants.animate,\n    transition: {\n      type: \"spring\",\n      duration: 0.45,\n      bounce: 0.35,\n      opacity: swapTransition,\n      filter: swapTransition,\n    },\n  },\n}\n\nexport type StatusButtonProps = Omit<\n  React.ComponentProps<typeof Button>,\n  \"onClick\" | \"children\"\n> & {\n  children: React.ReactNode\n  /**\n   * Uncontrolled mode: loading while the returned promise is pending, then\n   * success. Omit it when the form owns the submission.\n   */\n  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>\n  /** Controlled mode, e.g. derived from `useActionState`. */\n  status?: ButtonStatus\n  /** Also called for the automatic return to idle after `successDuration`. */\n  onStatusChange?: (status: ButtonStatus) => void\n  /** Shown next to the check icon, e.g. \"Submitted\" for a \"Submit\" button. */\n  successLabel?: React.ReactNode\n  successDuration?: number\n}\n\nexport function StatusButton({\n  children,\n  className,\n  onClick,\n  status: statusProp,\n  onStatusChange,\n  successLabel,\n  successDuration = DEFAULT_SUCCESS_DURATION,\n  ...props\n}: StatusButtonProps) {\n  const [status, setStatus] = useControllableState({\n    prop: statusProp,\n    defaultProp: \"idle\",\n    onChange: onStatusChange,\n    caller: \"StatusButton\",\n  })\n  const shouldReduceMotion = useReducedMotion()\n\n  useEffect(() => {\n    if (status !== \"success\") return\n\n    const timeoutId = window.setTimeout(\n      () => setStatus(\"idle\"),\n      successDuration\n    )\n    return () => window.clearTimeout(timeoutId)\n  }, [status, successDuration, setStatus])\n\n  const isBusy = status !== \"idle\"\n\n  const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {\n    if (isBusy) {\n      event.preventDefault()\n      return\n    }\n\n    if (!onClick) return\n\n    setStatus(\"loading\")\n\n    try {\n      await onClick(event)\n      setStatus(\"success\")\n    } catch (error) {\n      setStatus(\"idle\")\n      throw error\n    }\n  }\n\n  const variants = shouldReduceMotion ? reducedMotionVariants : swapVariants\n  const transition = shouldReduceMotion ? { duration: 0 } : swapTransition\n\n  const successContent = (\n    <>\n      <CheckIcon />\n      {successLabel ?? <span className=\"sr-only\">Success</span>}\n    </>\n  )\n\n  return (\n    <Button\n      data-status={status}\n      aria-busy={status === \"loading\"}\n      // Not `disabled`: busy keeps focus and the normal look, and the click\n      // handler ignores presses meanwhile.\n      aria-disabled={isBusy || undefined}\n      onClick={handleClick}\n      className={cn(\n        \"inline-grid justify-items-center *:col-start-1 *:row-start-1 *:flex *:items-center *:gap-[inherit]\",\n        className\n      )}\n      {...props}\n    >\n      {/* Invisible copies of the widest states keep the button's width fixed. */}\n      <span aria-hidden className=\"invisible\">\n        {children}\n      </span>\n      <span aria-hidden className=\"invisible\">\n        {successContent}\n      </span>\n\n      <AnimatePresence initial={false}>\n        {status === \"idle\" && (\n          <motion.span\n            key=\"idle\"\n            variants={variants}\n            initial=\"initial\"\n            animate=\"animate\"\n            exit=\"exit\"\n            transition={transition}\n          >\n            {children}\n          </motion.span>\n        )}\n\n        {status === \"loading\" && (\n          <motion.span\n            key=\"loading\"\n            role=\"status\"\n            variants={variants}\n            initial=\"initial\"\n            animate=\"animate\"\n            exit=\"exit\"\n            transition={transition}\n          >\n            <Spinner />\n            <span className=\"sr-only\">Loading</span>\n          </motion.span>\n        )}\n\n        {status === \"success\" && (\n          <motion.span\n            key=\"success\"\n            role=\"status\"\n            variants={shouldReduceMotion ? variants : successVariants}\n            initial=\"initial\"\n            animate=\"animate\"\n            exit=\"exit\"\n            transition={transition}\n          >\n            {successContent}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </Button>\n  )\n}\n\nfunction Spinner({ className, ...props }: React.ComponentProps<\"svg\">) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 16 16\"\n      fill=\"currentColor\"\n      className={cn(\"size-4 *:animate-spinner-opacity\", className)}\n      {...props}\n    >\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        style={{ animationDelay: \"-900ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(36 8 8)\"\n        style={{ animationDelay: \"-800ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(72 8 8)\"\n        style={{ animationDelay: \"-700ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(108 8 8)\"\n        style={{ animationDelay: \"-600ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(144 8 8)\"\n        style={{ animationDelay: \"-500ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(180 8 8)\"\n        style={{ animationDelay: \"-400ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(216 8 8)\"\n        style={{ animationDelay: \"-300ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(252 8 8)\"\n        style={{ animationDelay: \"-200ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(288 8 8)\"\n        style={{ animationDelay: \"-100ms\" }}\n      />\n      <rect\n        x=\"11.84\"\n        y=\"7.25\"\n        width=\"4\"\n        height=\"1.5\"\n        rx=\"0.75\"\n        transform=\"rotate(324 8 8)\"\n      />\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/status-button.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "--animate-spinner-opacity": "spinner-opacity var(--tw-animation-duration, var(--tw-duration, 1s)) linear infinite"
    }
  },
  "css": {
    "@keyframes spinner-opacity": {
      "from": {
        "opacity": "1"
      },
      "to": {
        "opacity": "0.15"
      }
    }
  },
  "docs": "https://chanhdai.com/components/status-button",
  "categories": [
    "controls"
  ],
  "type": "registry:component"
}