{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"status-button","type":"registry:component","title":"Status button","description":"Save, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/status-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * STATUS BUTTON: a button that tells you what happened\n *\n *   idle     \"Save\", with an optional icon\n *   pending  a spinner opens in; the label morphs to \"Saving\"\n *   success  a check draws itself; \"Saved\"\n *   error    the button gives a small shake; \"Try again\"\n *\n * Every change is one motion: the icon slot opens and closes,\n * icons swap through a blur, letters the labels share stay put\n * and the button's width eases to fit.\n * ───────────────────────────────────────────────────────── */\n\nexport type ActionStatus = \"idle\" | \"pending\" | \"success\" | \"error\";\n\nexport interface StatusButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  status?: ActionStatus;\n  /** Labels per state; defaults to Save, Saving, Saved, Try again. */\n  labels?: Partial<Record<ActionStatus, string>>;\n  /** Shown before the label while idle. */\n  icon?: React.ReactNode;\n  variant?: \"primary\" | \"outline\" | \"ghost\";\n  /** \"sm\" is 28px tall, for toolbars and headers. */\n  size?: \"default\" | \"sm\";\n  /** Called this long after success (ms), e.g. to go back to idle. */\n  onReset?: () => void;\n  resetAfter?: number;\n}\n\nconst LABELS: Record<ActionStatus, string> = { idle: \"Save\", pending: \"Saving\", success: \"Saved\", error: \"Try again\" };\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\nfunction Check({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${EASE} 120ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nfunction Alert() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n      <circle cx=\"8\" cy=\"11\" r=\"1\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nexport function StatusButton({\n  status = \"idle\",\n  labels,\n  icon,\n  variant = \"primary\",\n  size = \"default\",\n  onReset,\n  resetAfter = 1800,\n  className = \"\",\n  onClick,\n  ...props\n}: StatusButtonProps) {\n  const reduced = useReducedMotion();\n  const ref = React.useRef<HTMLButtonElement>(null);\n  const label = { ...LABELS, ...labels }[status];\n  const showIcon = status !== \"idle\" || Boolean(icon);\n  const busy = status === \"pending\";\n  const sm = size === \"sm\";\n  const iconSize = sm ? 14 : 16;\n\n  // A short shake when it fails: the eye catches it even if you looked away.\n  React.useEffect(() => {\n    if (status !== \"error\" || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [status, reduced]);\n\n  React.useEffect(() => {\n    if (status !== \"success\" || !onReset) return;\n    const timer = window.setTimeout(onReset, resetAfter);\n    return () => window.clearTimeout(timer);\n  }, [status, onReset, resetAfter]);\n\n  const tone =\n    status === \"error\"\n      ? \"bg-red-500/10 text-red-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] dark:text-red-400\"\n      : variant === \"primary\"\n        ? \"bg-primary text-primary-foreground hover:bg-primary/90\"\n        : variant === \"ghost\"\n          ? `hover:bg-accent hover:text-foreground ${status === \"idle\" ? \"text-muted-foreground\" : \"text-foreground\"}`\n          : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\";\n\n  const icons: Record<ActionStatus, React.ReactNode> = {\n    idle: icon,\n    // Spins only while pending, so a hidden spinner never keeps the page busy.\n    pending: (\n      <span\n        className={`${sm ? \"size-3\" : \"size-3.5\"} rounded-full border-[1.5px] border-current border-t-transparent opacity-80 motion-reduce:animate-none ${status === \"pending\" ? \"animate-spin\" : \"\"}`}\n      />\n    ),\n    success: <Check drawn={status === \"success\"} reduced={reduced} />,\n    error: <Alert />,\n  };\n\n  return (\n    <>\n      <button\n        ref={ref}\n        type=\"button\"\n        aria-busy={busy || undefined}\n        aria-disabled={busy || undefined}\n        onClick={(e) => {\n          if (busy) return e.preventDefault();\n          onClick?.(e);\n        }}\n        className={`inline-flex items-center justify-center whitespace-nowrap rounded-full font-medium transition-[background-color,color,box-shadow,transform,padding] duration-300 active:scale-[0.97] ${\n          sm ? `h-7 text-[12px] ${variant === \"ghost\" ? \"px-2.5\" : \"px-3\"}` : \"h-9 px-4 text-[13px]\"\n        } ${FOCUS} ${tone} ${className}`}\n        {...props}\n      >\n        <span\n          aria-hidden=\"true\"\n          className={`relative flex shrink-0 items-center justify-center ${sm ? \"h-3.5 [&_svg]:size-3.5\" : \"h-4 [&_svg]:size-4\"}`}\n          style={{\n            width: showIcon ? iconSize : 0,\n            marginRight: showIcon ? (sm ? 5 : 6) : 0,\n            transition: reduced ? \"none\" : `width 380ms ${EASE}, margin 380ms ${EASE}`,\n          }}\n        >\n          {(Object.keys(icons) as ActionStatus[]).map((s) => (\n            <span\n              key={s}\n              className=\"absolute inset-0 flex items-center justify-center\"\n              style={{\n                opacity: s === status ? 1 : 0,\n                transform: s === status ? \"none\" : \"scale(0.6)\",\n                filter: s === status ? \"none\" : \"blur(3px)\",\n                transition: reduced ? \"none\" : `opacity 260ms ${EASE}, transform 380ms ${EASE}, filter 260ms ${EASE}`,\n              }}\n            >\n              {icons[s]}\n            </span>\n          ))}\n        </span>\n        <TextMorph>{label}</TextMorph>\n      </button>\n      {/* Outside the button, so the announcement isn't read as part of its name. */}\n      <span className=\"sr-only\" role=\"status\">\n        {status === \"idle\" ? \"\" : label}\n      </span>\n    </>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/status-button"}