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