Morphs from its label to a spinner, then to a drawn tick or a shake, so every save feels answered.
Customize
Usage
import { SubmitButton } from "@/components/motionkit/submit-button";
<SubmitButton
action={async () => {
const response = await fetch("/api/settings", { method: "POST", body });
if (!response.ok) throw new Error("Save failed");
}}
>
Save changes
</SubmitButton>Motion
The exact curves this component runs, plotted with Motion's own maths.
Width morph
Status changes
Label swap
Status changes
Shake
The action fails
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/submit-button.tsx.
"use client";
import { useEffect, useState, type ComponentProps, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "idle" | "loading" | "success" | "error";
type SubmitButtonProps = Omit<ComponentProps<typeof motion.button>, "children" | "onClick" | "onSubmit"> & {
children: ReactNode;
/** Runs on click. Resolve for success; throw or reject for an error. */
action: () => Promise<unknown>;
/** Announced to screen readers while the action runs. */
loadingLabel?: string;
successLabel?: string;
errorLabel?: string;
/** Milliseconds the result shows before the button returns to its label. */
resetAfter?: number;
};
// petrol teal at rest, sage for success, a deep rust for errors; every pair clears 4.5:1
const palette: Record<Status, { background: string; color: string }> = {
idle: { background: "#0E7C86", color: "#FFFFFF" },
loading: { background: "#0E7C86", color: "#FFFFFF" },
success: { background: "#7D9E9D", color: "#1D2727" },
error: { background: "#8C3216", color: "#FFFFFF" },
};
export function SubmitButton({
children,
action,
loadingLabel = "Saving…",
successLabel = "Saved",
errorLabel = "Try again",
resetAfter = 2000,
className = "",
...props
}: SubmitButtonProps) {
const [status, setStatus] = useState<Status>("idle");
const reduceMotion = useReducedMotion();
useEffect(() => {
if (status !== "success" && status !== "error") return;
const timer = window.setTimeout(() => setStatus("idle"), resetAfter);
return () => window.clearTimeout(timer);
}, [status, resetAfter]);
async function handleClick() {
if (status === "loading") return;
setStatus("loading");
try {
await action();
setStatus("success");
} catch {
setStatus("error");
}
}
const announcement = { idle: "", loading: loadingLabel, success: successLabel, error: errorLabel }[status];
return (
<>
<motion.button
type="button"
{...props}
// aria-disabled rather than disabled, so focus stays on the button while it works
aria-disabled={status === "loading" || undefined}
aria-busy={status === "loading"}
onClick={handleClick}
layout={!reduceMotion}
initial={false}
animate={{
...palette[status],
x: status === "error" && !reduceMotion ? [0, -7, 7, -5, 5, -2, 0] : 0,
}}
transition={{
default: { duration: 0.25 },
x: { duration: 0.45 },
layout: { type: "spring", bounce: 0.2, duration: 0.45 },
}}
// the radius lives in style so layout animation keeps the corners round as the width changes
style={{ borderRadius: 12 }}
className={`relative inline-flex h-11 min-w-11 items-center justify-center overflow-hidden px-5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] ${
status === "loading" ? "cursor-progress" : ""
} ${className}`}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 14, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -14, filter: "blur(4px)" }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
className="inline-flex items-center gap-2 whitespace-nowrap"
>
{status === "idle" && children}
{status === "loading" && <Spinner still={!!reduceMotion} />}
{status === "success" && (
<>
<Check still={!!reduceMotion} /> {successLabel}
</>
)}
{status === "error" && (
<>
<Alert /> {errorLabel}
</>
)}
</motion.span>
</AnimatePresence>
</motion.button>
<span className="sr-only" aria-live="polite">
{announcement}
</span>
</>
);
}
function Spinner({ still }: { still: boolean }) {
return (
<motion.svg
aria-hidden
viewBox="0 0 24 24"
className="size-[18px]"
animate={still ? undefined : { rotate: 360 }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity={0.25} strokeWidth={2.5} />
<path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" />
</motion.svg>
);
}
function Check({ still }: { still: boolean }) {
return (
<svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
initial={{ pathLength: still ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, delay: 0.08, ease: "easeOut" }}
/>
</svg>
);
}
function Alert() {
return (
<svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5v5M12 16.5h.01" />
</svg>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| action | () => Promise<unknown> | — | Runs on click. Resolve for success; throw or reject for an error. |
| loadingLabel | string | "Saving…" | Announced to screen readers while the action runs. |
| successLabel | string | "Saved" | Shown beside the tick. |
| errorLabel | string | "Try again" | Shown after a failure. Clicking again retries. |
| resetAfter | number | 2000 | Milliseconds the result shows before the label returns. |
| ...props | motion.button props | — | Any other button or Motion prop. |
Magnetic Button
A button that leans toward the cursor with a springy, physical pull.
Shimmer Button
A dark button with a light sweep that draws the eye without shouting.
Hold to Confirm
A button that fills while held, so destructive actions can never happen by accident.