Checkout Step Progress
The finished stage folds into a one-line recap while the following one opens and the rail fills.
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 { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Checkout Step Progress
*
* Checkout advances by folding what is settled out of the way: the step
* you just finished collapses into a one-line summary, the following one
* opens in the space it left, and the rail beside them fills to where
* you are.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the flow reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `steps`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CheckoutStep = {
id: string;
/** Heading of the step. */
label: string;
/** One-line recap once the step is behind you. */
summary: string;
/** Placeholder text for the two sample fields. */
fields: string[];
/** Label of the control that closes this step. */
action: string;
};
export type CheckoutStepProgressProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The stages of the flow, in order. */
steps?: CheckoutStep[];
/** Accent for the rail, the dots and the primary control. */
accent?: string;
/** Line shown once every stage is behind you. */
completeLabel?: string;
/** Fires with the index of each stage as it closes. */
onAdvance?: (index: number) => void;
};
type VariantConfig = {
/** How long a body takes to fold away or open. */
foldSeconds: number;
/** Crossfade for the summary line replacing the body. */
swapSeconds: number;
/** Spring the rail fill rides to the current stage. */
rail: { type: "spring"; stiffness: number; damping: number };
};
// A flow that overshoots looks unsure of where it is. Rail damping
// ratios (damping / 2√stiffness) stay at or above 0.9, and the folds are
// plain eased tweens — a height change that springs reads as rubber.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Brisk. For a returning customer who has done this before.
subtle: {
foldSeconds: 0.22,
swapSeconds: 0.14,
rail: { type: "spring", stiffness: 520, damping: 46 },
},
// Enough time to see what folded and what opened. All-purpose.
default: {
foldSeconds: 0.32,
swapSeconds: 0.2,
rail: { type: "spring", stiffness: 380, damping: 36 },
},
// A longer fold for a first checkout where the structure is new.
playful: {
foldSeconds: 0.42,
swapSeconds: 0.24,
rail: { type: "spring", stiffness: 280, damping: 32 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_STEPS: CheckoutStep[] = [
{
id: "contact",
label: "Contact",
summary: "casey@northwind.co",
fields: ["Email address", "Phone"],
action: "Continue",
},
{
id: "delivery",
label: "Delivery",
summary: "18 Rowan Street, Leeds",
fields: ["Street address", "Postcode"],
action: "Continue",
},
{
id: "payment",
label: "Payment",
summary: "Visa ending 4242",
fields: ["Card number", "Expiry and CVC"],
action: "Pay $267.42",
},
];
function CheckGlyph() {
return (
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" aria-hidden>
<path
d="M2.8 7.4 5.6 10.2 11.2 4"
stroke="#FFFFFF"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function CheckoutStepProgress({
variant = "default",
steps = DEFAULT_STEPS,
accent = "#7C7CF0",
completeLabel = "Paid in full · a receipt is on its way",
onAdvance,
}: CheckoutStepProgressProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [current, setCurrent] = useState(0);
const done = current >= steps.length;
const advance = () => {
if (current >= steps.length) return;
onAdvance?.(current);
setCurrent(current + 1);
};
return (
<div
style={{
position: "relative",
width: 282,
display: "grid",
gridTemplateColumns: "18px 1fr",
columnGap: 12,
fontSize: 13,
}}
>
<div style={{ gridColumn: 2, display: "grid" }}>
{steps.map((step, index) => {
const state =
index < current ? "done" : index === current ? "open" : "waiting";
const last = index === steps.length - 1;
return (
<div
key={step.id}
style={{
position: "relative",
paddingBottom: last ? 0 : 14,
}}
>
{/* Each stage carries its own length of rail down to the
one below, so the connector is always exactly as long as
the stage above it — no measuring, no drift when a body
folds. The fill is a scaleY transform: zero layout cost. */}
{!last && (
<span aria-hidden>
<span
style={{
position: "absolute",
left: -22,
top: 22,
bottom: 2,
width: 2,
borderRadius: 2,
background: tone(12),
}}
/>
<motion.span
initial={false}
animate={{ scaleY: index < current ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.rail}
style={{
position: "absolute",
left: -22,
top: 22,
bottom: 2,
width: 2,
borderRadius: 2,
background: accent,
transformOrigin: "50% 0%",
}}
/>
</span>
)}
{/* Dot lives in the rail column but is positioned from the
row, so it always lines up with its own heading. */}
<span
aria-hidden
style={{
position: "absolute",
left: -30,
top: 3,
width: 18,
height: 18,
display: "grid",
placeItems: "center",
borderRadius: 999,
background: state === "waiting" ? tone(10) : accent,
border:
state === "open"
? `3px solid color-mix(in srgb, ${accent} 30%, transparent)`
: "none",
boxSizing: "border-box",
}}
>
{state === "done" ? <CheckGlyph /> : null}
</span>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
minHeight: 20,
}}
>
<span
style={{
fontSize: 12.5,
fontWeight: 600,
opacity: state === "waiting" ? 0.45 : 1,
}}
>
{step.label}
</span>
{/* The recap and the empty state share one cell, so the
swap can never nudge the heading beside them. */}
<span
style={{
marginLeft: "auto",
display: "grid",
justifyItems: "end",
minWidth: 0,
}}
>
<motion.span
initial={false}
animate={{ opacity: state === "done" ? 0.55 : 0 }}
transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
style={{
gridArea: "1 / 1",
fontSize: 11.5,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{step.summary}
</motion.span>
</span>
</div>
{/* The body is a genuine size change, so it tweens on height
— short and eased, with the contents fading a little
ahead of the fold so nothing is caught mid-clip. */}
<motion.div
initial={false}
animate={{
height: state === "open" ? "auto" : 0,
opacity: state === "open" ? 1 : 0,
}}
transition={
reduceMotion
? { duration: 0 }
: {
height: { duration: cfg.foldSeconds, ease: [0.3, 0, 0.2, 1] },
opacity: {
duration: cfg.swapSeconds,
ease: "easeOut",
delay: state === "open" ? cfg.foldSeconds * 0.4 : 0,
},
}
}
style={{ overflow: "hidden" }}
aria-hidden={state !== "open"}
>
<div style={{ display: "grid", gap: 7, paddingTop: 8 }}>
{step.fields.map((field) => (
<input
key={field}
type="text"
placeholder={field}
tabIndex={state === "open" ? 0 : -1}
style={{
height: 32,
padding: "0 10px",
fontSize: 12.5,
fontFamily: "inherit",
color: "inherit",
background: tone(6),
border: `1px solid ${tone(12)}`,
borderRadius: 8,
outline: "none",
width: "100%",
boxSizing: "border-box",
}}
/>
))}
<button
type="button"
onClick={advance}
tabIndex={state === "open" ? 0 : -1}
style={{
marginTop: 2,
height: 34,
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: "#FFFFFF",
background: accent,
border: 0,
borderRadius: 9,
cursor: "pointer",
}}
>
{step.action}
</button>
</div>
</motion.div>
</div>
);
})}
</div>
<motion.div
initial={false}
animate={{ opacity: done ? 1 : 0, y: done || reduceMotion ? 0 : 4 }}
transition={{
duration: cfg.swapSeconds,
ease: "easeOut",
delay: done && !reduceMotion ? cfg.foldSeconds * 0.6 : 0,
}}
aria-live="polite"
style={{
gridColumn: "1 / -1",
marginTop: 12,
fontSize: 12,
fontWeight: 600,
color: accent,
}}
>
{done ? completeLabel : ""}
</motion.div>
</div>
);
}About this pattern
Checkout gets shorter as you go. Closing a stage folds its fields away on a short eased height tween and leaves a single recap line in their place, the stage below opens into the room that freed up, and the connector beside them fills to mark what is behind you. Keeping exactly one stage open means the page never grows while it is being read, and the recap lines double as the edit affordance. Nothing springs on height — a form that bounces open reads as rubber, not as progress.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Each closed section becomes a single editable recap line above the open one.
Related patterns
- Wizard Step AdvanceThe rail fills toward the step you are entering while the panel travels the way you sent it.
- Cart Item RemoveA removed line collapses its own height and gap while the survivors travel up and the total falls.
- Secure Checkout BadgeA lock strokes itself shut and the payment fields firm up behind it.