{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loading-button",
  "type": "registry:ui",
  "title": "Loading button",
  "description": "Keeps pending, success, and error feedback inside one action.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "actions"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/loading-button/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Pending, success, and failure hand off inside one button",
    "sceneFamily": "product-mono",
    "motionRole": "snap",
    "primaryState": "Submit button with in-place progress feedback",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/loading-button.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/loading-button.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type AsyncActionStatus = \"idle\" | \"pending\" | \"success\" | \"error\";\n\nexport type UseAsyncActionOptions = {\n  action: () => unknown;\n  resetAfter?: number;\n  onError?: (error: unknown) => void;\n};\n\nexport function useAsyncAction({\n  action,\n  resetAfter = 1400,\n  onError,\n}: UseAsyncActionOptions) {\n  const [status, setStatus] = useState<AsyncActionStatus>(\"idle\");\n\n  const phase = useRef<AsyncActionStatus>(\"idle\");\n  const runId = useRef(0);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const alive = useRef(true);\n\n  const act = useRef(action);\n  const fail = useRef(onError);\n\n  useEffect(() => {\n    act.current = action;\n    fail.current = onError;\n  });\n\n  const clear = useCallback(() => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n  }, []);\n\n  const reset = useCallback(() => {\n    runId.current += 1;\n    clear();\n    phase.current = \"idle\";\n    setStatus(\"idle\");\n  }, [clear]);\n\n  const run = useCallback(() => {\n    if (phase.current === \"pending\") return;\n\n    clear();\n    const id = ++runId.current;\n    phase.current = \"pending\";\n    setStatus(\"pending\");\n\n    const settle = (next: \"success\" | \"error\") => {\n      if (!alive.current || id !== runId.current) return;\n      clear();\n      phase.current = next;\n      setStatus(next);\n      timer.current = setTimeout(() => {\n        if (!alive.current || id !== runId.current) return;\n        phase.current = \"idle\";\n        setStatus(\"idle\");\n      }, resetAfter);\n    };\n\n    Promise.resolve()\n      .then(() => act.current())\n      .then(\n        () => settle(\"success\"),\n        (error: unknown) => {\n          fail.current?.(error);\n          settle(\"error\");\n        },\n      );\n  }, [clear, resetAfter]);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      clear();\n    };\n  }, [clear]);\n\n  return {\n    status,\n    run,\n    reset,\n    pending: status === \"pending\",\n  };\n}\n\nfunction Spinner({ still }: { still: boolean }) {\n  return (\n    <motion.svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className=\"shrink-0\"\n      animate={still ? undefined : { rotate: 360 }}\n      transition={\n        still ? undefined : { duration: 0.85, repeat: Infinity, ease: \"linear\" }\n      }\n    >\n      <circle\n        cx=\"6\"\n        cy=\"6\"\n        r=\"4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeOpacity=\"0.22\"\n      />\n      <path\n        d=\"M10.5 6A4.5 4.5 0 0 0 6 1.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n      />\n    </motion.svg>\n  );\n}\n\nfunction CheckMark() {\n  return (\n    <svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className=\"shrink-0\"\n    >\n      <path\n        d=\"M2.6 6.3 4.9 8.6 9.4 3.6\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.7\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction AlertMark() {\n  return (\n    <svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className=\"shrink-0\"\n    >\n      <path d=\"M6 2.9v3.5\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n      <path d=\"M6 9.05h.01\" stroke=\"currentColor\" strokeWidth=\"1.9\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport type LoadingButtonProps = {\n  onAction: () => unknown;\n  children: string;\n  pendingLabel?: string;\n  successLabel?: string;\n  errorLabel?: string;\n  resetAfter?: number;\n  disabled?: boolean;\n  onError?: (error: unknown) => void;\n  className?: string;\n};\n\nexport function LoadingButton({\n  onAction,\n  children,\n  pendingLabel = children,\n  successLabel = \"Done\",\n  errorLabel = \"Try again\",\n  resetAfter = 1400,\n  disabled = false,\n  onError,\n  className = \"\",\n}: LoadingButtonProps) {\n  const reduced = useReducedMotion();\n\n  const { status, run, pending } = useAsyncAction({\n    action: onAction,\n    resetAfter,\n    onError,\n  });\n\n  const fade = reduced ? INSTANT : CROSSFADE;\n\n\n  const label =\n    status === \"pending\"\n      ? pendingLabel\n      : status === \"success\"\n        ? successLabel\n        : status === \"error\"\n          ? errorLabel\n          : children;\n\n  const faces = [\n    {\n      key: \"idle\",\n      text: children,\n      tone: \"text-stone-700 dark:text-stone-200\",\n      icon: null,\n    },\n    {\n      key: \"pending\",\n      text: pendingLabel,\n      tone: \"text-stone-500 dark:text-stone-400\",\n      icon: <Spinner still={reduced === true || status !== \"pending\"} />,\n    },\n    {\n      key: \"success\",\n      text: successLabel,\n      tone: \"text-emerald-600 dark:text-emerald-400\",\n      icon: <CheckMark />,\n    },\n    {\n      key: \"error\",\n      text: errorLabel,\n      tone: \"text-red-600 dark:text-red-400\",\n      icon: <AlertMark />,\n    },\n  ];\n\n  return (\n    <>\n      <motion.button\n        type=\"button\"\n        disabled={disabled}\n        aria-label={label}\n        aria-busy={pending || undefined}\n        aria-disabled={pending || undefined}\n        whileTap={disabled || pending || reduced ? undefined : { y: 1 }}\n        transition={CELL}\n        onClick={(event) => {\n          if (pending) {\n            event.preventDefault();\n            return;\n          }\n          run();\n        }}\n        className={`relative inline-flex h-9 select-none items-center justify-center rounded-[9px] border border-stone-200 bg-white px-3.5 text-[13px] font-medium text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[border-color,box-shadow,background-color] duration-150 hover:bg-stone-50 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#252522] dark:text-stone-200 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${className}`}\n        style={{ borderRadius: 9, touchAction: \"manipulation\" }}\n      >\n        <span aria-hidden className=\"relative grid place-items-center\">\n          {faces.map((face) => (\n            <motion.span\n              key={face.key}\n              initial={false}\n              animate={\n                face.key === status\n                  ? { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                  : { opacity: 0, y: 3, filter: \"blur(3px)\" }\n              }\n              transition={fade}\n              className={`col-start-1 row-start-1 flex items-center justify-center gap-1.5 whitespace-nowrap ${face.tone}`}\n            >\n              {face.icon}\n              {face.text}\n            </motion.span>\n          ))}\n        </span>\n      </motion.button>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status === \"success\" ? successLabel : status === \"error\" ? errorLabel : \"\"}\n      </span>\n    </>\n  );\n}\n"
    }
  ]
}
