Done
Source code
Copy and paste into your project
SubmitButton.tsx
pnpm add framer-motion
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
type ButtonState = "idle" | "loading" | "success" | "error";
const EASE_OUT = [0.16, 1, 0.3, 1] as const;
/** The idle width is fixed, so the collapse to a circle is a pure size animation. */
const IDLE_WIDTH = 148;
const IDLE_PADDING = 28;
const SUCCESS_SIZE = 48;
const CROSSFADE_S = 0.3;
const REDUCED_CROSSFADE_S = 0.12;
const CHECK_DELAY_S = 0.06;
const CHECK_DRAW_S = 0.3;
const DEMO_LATENCY_MS = 1200;
/** Held *after* the check finishes drawing, not from the state flip. */
const SUCCESS_HOLD_MS = 1200;
const ERROR_HOLD_MS = 2600;
/** Announced by the live region, so state changes reach screen readers. */
const STATUS_MESSAGE: Record<ButtonState, string> = {
idle: "",
loading: "Submitting",
success: "Submitted",
error: "Something went wrong. Try again.",
};
/** The button's own name — always matches or describes what is on screen. */
const ACCESSIBLE_NAME: Record<ButtonState, string> = {
idle: "Submit",
loading: "Submitting",
success: "Submitted",
error: "Try again",
};
/** White on both #15803d and #b91c1c clears 4.5:1. */
const BACKGROUND: Record<ButtonState, string> = {
idle: "var(--submit-button-bg)",
loading: "var(--submit-button-bg)",
success: "#15803d",
error: "#b91c1c",
};
const FOREGROUND: Record<ButtonState, string> = {
idle: "var(--submit-button-fg)",
loading: "var(--submit-button-fg)",
success: "#ffffff",
error: "#ffffff",
};
export function SubmitButton({
onSubmit,
}: {
/**
* Runs on click. Resolve to land on success, throw or reject to land on
* error. Leave it off and the button simulates a request instead.
*/
onSubmit?: () => void | Promise<void>;
}) {
const reduceMotion = useReducedMotion() ?? false;
const [state, setState] = useState<ButtonState>("idle");
const resetTimer = useRef<number | null>(null);
const mounted = useRef(true);
const crossfade = reduceMotion ? REDUCED_CROSSFADE_S : CROSSFADE_S;
// The check only starts drawing once the spinner has crossfaded out. Hold
// from the end of that, or success resets while it is still animating in.
const successEntranceMs =
(reduceMotion ? crossfade : crossfade + CHECK_DELAY_S + CHECK_DRAW_S) * 1000;
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
};
}, []);
async function submit() {
// "error" stays clickable so a failed submit can be retried immediately.
if (state === "loading" || state === "success") return;
if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
setState("loading");
try {
if (onSubmit) await onSubmit();
else await new Promise((resolve) => setTimeout(resolve, DEMO_LATENCY_MS));
if (!mounted.current) return;
setState("success");
resetTimer.current = window.setTimeout(
() => setState("idle"),
successEntranceMs + SUCCESS_HOLD_MS,
);
} catch {
if (!mounted.current) return;
setState("error");
resetTimer.current = window.setTimeout(
() => setState("idle"),
ERROR_HOLD_MS,
);
}
}
const busy = state === "loading" || state === "success";
const transition = { duration: crossfade, ease: EASE_OUT };
return (
<>
<span role="status" aria-live="polite" className="sr-only">
{STATUS_MESSAGE[state]}
</span>
<motion.button
type="button"
onClick={submit}
// aria-disabled rather than disabled: disabling a focused button drops
// focus to the body, which strands keyboard users mid-interaction.
aria-disabled={busy}
aria-busy={state === "loading"}
aria-label={ACCESSIBLE_NAME[state]}
animate={{
width: state === "success" ? SUCCESS_SIZE : IDLE_WIDTH,
// Padding has to collapse with the width. Left in place, the 28px
// gutters stop the button short of 48px and squeeze the check to 0.
paddingLeft: state === "success" ? 0 : IDLE_PADDING,
paddingRight: state === "success" ? 0 : IDLE_PADDING,
backgroundColor: BACKGROUND[state],
color: FOREGROUND[state],
}}
whileTap={
!reduceMotion && state === "idle" ? { scale: 0.96 } : undefined
}
transition={transition}
// w-[148px] and px-7 mirror IDLE_WIDTH/IDLE_PADDING as the pre-hydration
// fallback; Framer replaces both with inline styles once it mounts.
className="flex h-12 w-[148px] items-center justify-center overflow-hidden rounded-full px-7 font-medium [--submit-button-bg:#111111] [--submit-button-fg:#ffffff] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-600 aria-disabled:cursor-default dark:[--submit-button-bg:#f5f5f5] dark:[--submit-button-fg:#111111]"
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={state}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={transition}
className="flex items-center justify-center whitespace-nowrap"
>
{state === "idle" && "Submit"}
{/* A frozen spinner reads as a hung control, so reduced motion
gets the word instead of the spin. */}
{state === "loading" && (reduceMotion ? "Submitting" : <Spinner />)}
{state === "success" && <Check animate={!reduceMotion} />}
{state === "error" && "Try again"}
</motion.span>
</AnimatePresence>
</motion.button>
</>
);
}
function Spinner() {
return (
<motion.svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="shrink-0"
animate={{ rotate: 360 }}
transition={{ duration: 0.4, ease: "linear", repeat: Infinity }}
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2.5"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</motion.svg>
);
}
function Check({ animate }: { animate: boolean }) {
return (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="shrink-0"
>
<motion.path
d="M5.5 12.5 10 17l8.5-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={animate ? { pathLength: 0, opacity: 0 } : false}
animate={{ pathLength: 1, opacity: 1 }}
transition={
animate
? { duration: CHECK_DRAW_S, ease: EASE_OUT, delay: CHECK_DELAY_S }
: { duration: 0 }
}
/>
</svg>
);
}
Loading feedback should feel like one continuous state change, not three separate UI swaps. Click the button: the label yields to a spinner, the spinner settles into a green checkmark, then the control soft-resets so you can try it again.
The timings are deliberate — about 1.2s of loading so the spinner is readable, then a short hold on success before returning to idle. Reduced-motion users get a quick crossfade between states instead of the spin and check draw.