Form Submit Progress
Submit narrows to a turning disc while the request runs, then opens out as the confirmed state.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Form Submit Progress
*
* A submit control that narrows to a turning disc while the request is
* in flight and opens back out as the confirmed state. The row it sits
* in keeps its size throughout, so nothing under the button moves.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `label`, `workMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FormSubmitProgressProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Resting label. */
label?: string;
/** Label while the request is in flight — read out to assistive tech. */
workingLabel?: string;
/** Label once the request has landed. */
doneLabel?: string;
/** How long the sample request takes, in ms. */
workMs?: number;
/** How long the confirmed state is held before returning to rest, in ms. */
resetMs?: number;
/** Width of the resting control, in px. Also the reserved row width. */
restWidth?: number;
/** Width of the confirmed control, in px. */
doneWidth?: number;
/** Fires when the control is pressed. */
onSubmit?: () => void;
/** Fires when the confirmed state has been reached. */
onDone?: () => void;
};
type Phase = "idle" | "working" | "done";
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
swapDuration: number;
spinSeconds: number;
};
// The control carries a word while it changes size, so the width spring
// has to arrive without overshoot — every ratio here is at or above 0.8.
// Variants differ in how fast the collapse runs, never in its character.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Snaps down and back with no visible travel. For toolbars.
subtle: {
spring: { type: "spring", stiffness: 610, damping: 50 },
swapDuration: 0.08,
spinSeconds: 1,
},
default: {
spring: { type: "spring", stiffness: 380, damping: 34 },
swapDuration: 0.14,
spinSeconds: 0.8,
},
// Slower, more deliberate collapse — for a single hero action.
playful: {
spring: { type: "spring", stiffness: 270, damping: 27 },
swapDuration: 0.2,
spinSeconds: 0.6,
},
};
const ACCENT = "#7C7CF0";
const DONE = "#10B981";
const HEIGHT = 44;
export default function FormSubmitProgress({
variant = "default",
label = "Create invoice",
workingLabel = "Creating invoice",
doneLabel = "Invoice created",
workMs = 1500,
resetMs = 1900,
restWidth = 200,
doneWidth = 186,
onSubmit,
onDone,
}: FormSubmitProgressProps) {
const [phase, setPhase] = useState<Phase>("idle");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The callback lives in a ref so an inline arrow from the parent can't
// re-trigger the effect and restart the request part-way through.
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
useEffect(() => {
if (phase !== "working") return;
const timer = setTimeout(() => {
setPhase("done");
onDoneRef.current?.();
}, workMs);
return () => clearTimeout(timer);
}, [phase, workMs]);
useEffect(() => {
if (phase !== "done") return;
const timer = setTimeout(() => setPhase("idle"), resetMs);
return () => clearTimeout(timer);
}, [phase, resetMs]);
// Reduced motion: the control keeps its width and its position, and
// the state is carried entirely by the label and the color. The
// information survives; only the travel is dropped.
const width = reduceMotion
? restWidth
: phase === "working"
? HEIGHT
: phase === "done"
? doneWidth
: restWidth;
const swap = { duration: cfg.swapDuration, ease: "easeOut" } as const;
const ring = (
<motion.svg
width="19"
height="19"
viewBox="0 0 16 16"
fill="none"
aria-hidden
animate={reduceMotion ? undefined : { rotate: 360 }}
transition={{ duration: cfg.spinSeconds, ease: "linear", repeat: Infinity }}
>
<circle cx="8" cy="8" r="6.2" stroke="rgba(255,255,255,0.34)" strokeWidth="2" />
<path
d="M8 1.8a6.2 6.2 0 0 1 6.2 6.2"
stroke="#FFFFFF"
strokeWidth="2"
strokeLinecap="round"
/>
</motion.svg>
);
return (
// The row reserves the resting width for the whole sequence, so the
// form below the button never shifts while the request runs.
<div
style={{
width: restWidth,
height: HEIGHT,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<motion.button
type="button"
disabled={phase !== "idle"}
onClick={() => {
if (phase !== "idle") return;
setPhase("working");
onSubmit?.();
}}
initial={false}
animate={{
width,
// Hex to hex, so the color genuinely interpolates rather than
// switching on a frame boundary.
backgroundColor: phase === "done" ? DONE : ACCENT,
}}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { ...cfg.spring, backgroundColor: { duration: 0.24, ease: "easeOut" } }
}
style={{
position: "relative",
height: HEIGHT,
borderRadius: 12,
border: 0,
padding: 0,
overflow: "hidden",
color: "#FFFFFF",
fontFamily: "inherit",
cursor: phase === "idle" ? "pointer" : "default",
}}
>
{/* Three layers stacked in the same box and crossfaded. Each one
is centered and non-wrapping, so a label is never re-flowed by
the width it is sitting inside — it fades, it does not reset. */}
<motion.span
initial={false}
animate={{ opacity: phase === "idle" ? 1 : 0 }}
transition={swap}
style={layerStyle}
>
<span style={{ fontSize: 14, fontWeight: 600, whiteSpace: "nowrap" }}>
{label}
</span>
</motion.span>
<motion.span
initial={false}
animate={{ opacity: phase === "working" ? 1 : 0 }}
transition={swap}
style={layerStyle}
>
{ring}
{reduceMotion ? (
<span
style={{
fontSize: 14,
fontWeight: 600,
marginLeft: 9,
whiteSpace: "nowrap",
}}
>
{workingLabel}
</span>
) : null}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: phase === "done" ? 1 : 0 }}
transition={swap}
style={layerStyle}
>
<svg width="17" height="17" viewBox="0 0 16 16" fill="none" aria-hidden>
<motion.path
d="M3.4 8.4 6.3 11.3 12.6 5"
stroke="#FFFFFF"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: phase === "done" ? 1 : 0 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.26, ease: "easeOut", delay: 0.04 }
}
/>
</svg>
<span
style={{
fontSize: 14,
fontWeight: 600,
marginLeft: 8,
whiteSpace: "nowrap",
}}
>
{doneLabel}
</span>
</motion.span>
{/* One live region for the whole control: the visual layers are
crossfades, and a screen reader should hear one state. */}
<span
role="status"
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
}}
>
{phase === "working" ? workingLabel : phase === "done" ? doneLabel : ""}
</span>
</motion.button>
</div>
);
}
const layerStyle = {
position: "absolute" as const,
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none" as const,
};About this pattern
Three states in one control, with the form behind it holding perfectly still. The button collapses to a disc for the length of the request and opens back out to report what happened, while the row around it keeps the resting width the whole time — so nothing under the button shifts and no one loses their place. The label, the disc and the confirmation are three stacked layers crossfading inside a single box: text is never re-flowed by the width it sits in, it just fades. Pressing is locked out until the control is back at rest, which is the cheapest double-submit guard there is.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
Submit controls resolve inline instead of throwing a blocking overlay.
Related patterns
- Delete Confirm MorphA delete control widens in place into a question with a way out, instead of opening a dialog.
- Destructive Hold to ConfirmHolding fills the control at an honest rate; letting go early drains it back several times faster.
- Error Retry NudgeA failed action answers with one short damped nudge and becomes its own retry.