## 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 : ""} ); } ```