## Loading Button — Action Feedback
Label to state without layout shift.
Docs: https://www.interior.dev/docs/loading-button
Reference: https://www.interior.dev/reference/loading-button
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/loading-button.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { useRouter } from "next/navigation";
import { LoadingButton } from "@/components/interior/loading-button";
export function PublishRelease({ id }: { id: string }) {
const router = useRouter();
return (
{
const res = await fetch(`/api/releases/${id}/publish`, { method: "POST" });
if (!res.ok) throw new Error(await res.text());
router.refresh();
}}
pendingLabel="Publishing…"
successLabel="Published"
errorLabel="Retry"
onError={(error) => console.error(error)}
>
Publish
);
}
```
### Props
- `onAction` (`() => unknown`)
Runs on click. Sync throws and rejected promises both settle the button into its error state; anything else settles it into success.
- `children` (`string`) — default: `—`
The idle label. A string rather than a node because it also becomes the button's accessible name.
- `pendingLabel` (`string`) — default: `children`
Label while the action is in flight. Defaults to the idle label so the button can stay silent and let the meter speak.
- `successLabel` (`string`) — default: `"Done"`
Label after the action resolves, shown with a check mark.
- `errorLabel` (`string`) — default: `"Try again"`
Label after the action rejects, shown with an alert mark. Make it the next action, not the diagnosis.
- `resetAfter` (`number`) — default: `1400`
Milliseconds the settled state is held before returning to idle.
- `disabled` (`boolean`) — default: `false`
Genuinely unavailable, as opposed to busy. Only this sets the native disabled attribute.
- `onError` (`(error: unknown) => void`)
Receives the rejection value. The button reports the state; reporting the error is the caller's job.
- `className` (`string`) — default: `""`
Appended last, so callers can override the surface, radius or type scale.
### Behavior notes
- All four labels are rendered into a single grid cell, so the widest one reserves the button's width before the first click; "Publish" becoming "Publishing…" cannot grow the button or shove the controls beside it.
- The button is never given the native disabled attribute while a request is in flight — it carries aria-busy and aria-disabled instead — so a keyboard user's focus is not dropped onto the body the moment they press Enter.
- A second click during flight is ignored, and every run carries an id, so a slow first response can never overwrite the result of the run that replaced it.
- Pending is indeterminate, because the button does not know how long the request will take. A meter filling against a guessed duration claims progress it cannot know, so the wait is shown as a wait.
- The rAF loop and the reset timer are cancelled on unmount and every settlement checks an alive flag, so a promise resolving after navigation sets no state.
- Under prefers-reduced-motion the meter's rAF never starts and the state lands instantly; the label still changes and a polite status region announces success or failure exactly once, not on every tick.
### Source (`components/interior/loading-button.tsx`)
```tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;
const INSTANT = { duration: 0 } as const;
export type AsyncActionStatus = "idle" | "pending" | "success" | "error";
export type UseAsyncActionOptions = {
action: () => unknown;
resetAfter?: number;
onError?: (error: unknown) => void;
};
export function useAsyncAction({
action,
resetAfter = 1400,
onError,
}: UseAsyncActionOptions) {
const [status, setStatus] = useState("idle");
const phase = useRef("idle");
const runId = useRef(0);
const timer = useRef | null>(null);
const alive = useRef(true);
const act = useRef(action);
const fail = useRef(onError);
useEffect(() => {
act.current = action;
fail.current = onError;
});
const clear = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
const reset = useCallback(() => {
runId.current += 1;
clear();
phase.current = "idle";
setStatus("idle");
}, [clear]);
const run = useCallback(() => {
if (phase.current === "pending") return;
clear();
const id = ++runId.current;
phase.current = "pending";
setStatus("pending");
const settle = (next: "success" | "error") => {
if (!alive.current || id !== runId.current) return;
clear();
phase.current = next;
setStatus(next);
timer.current = setTimeout(() => {
if (!alive.current || id !== runId.current) return;
phase.current = "idle";
setStatus("idle");
}, resetAfter);
};
Promise.resolve()
.then(() => act.current())
.then(
() => settle("success"),
(error: unknown) => {
fail.current?.(error);
settle("error");
},
);
}, [clear, resetAfter]);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
clear();
};
}, [clear]);
return {
status,
run,
reset,
pending: status === "pending",
};
}
function Spinner({ still }: { still: boolean }) {
return (
);
}
function CheckMark() {
return (
);
}
function AlertMark() {
return (
);
}
export type LoadingButtonProps = {
onAction: () => unknown;
children: string;
pendingLabel?: string;
successLabel?: string;
errorLabel?: string;
resetAfter?: number;
disabled?: boolean;
onError?: (error: unknown) => void;
className?: string;
};
export function LoadingButton({
onAction,
children,
pendingLabel = children,
successLabel = "Done",
errorLabel = "Try again",
resetAfter = 1400,
disabled = false,
onError,
className = "",
}: LoadingButtonProps) {
const reduced = useReducedMotion();
const { status, run, pending } = useAsyncAction({
action: onAction,
resetAfter,
onError,
});
const fade = reduced ? INSTANT : CROSSFADE;
const label =
status === "pending"
? pendingLabel
: status === "success"
? successLabel
: status === "error"
? errorLabel
: children;
const faces = [
{
key: "idle",
text: children,
tone: "text-stone-700 dark:text-stone-200",
icon: null,
},
{
key: "pending",
text: pendingLabel,
tone: "text-stone-500 dark:text-stone-400",
icon: ,
},
{
key: "success",
text: successLabel,
tone: "text-emerald-600 dark:text-emerald-400",
icon: ,
},
{
key: "error",
text: errorLabel,
tone: "text-red-600 dark:text-red-400",
icon: ,
},
];
return (
<>
{
if (pending) {
event.preventDefault();
return;
}
run();
}}
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}`}
style={{ borderRadius: 9, touchAction: "manipulation" }}
>
{faces.map((face) => (
{face.icon}
{face.text}
))}
{status === "success" ? successLabel : status === "error" ? errorLabel : ""}
>
);
}
```