Wizard Step Advance
The rail fills toward the step you are entering while the panel travels the way you sent it.
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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Wizard Step Advance
*
* Two things move on every advance and they agree with each other: the
* rail fills toward the step you are entering, and the panel travels in
* the direction you asked for. Going back reverses both.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultStep`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type WizardStepAdvanceProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Step shown on first render, zero-based. */
defaultStep?: number;
/** Accent for completed markers and the filled rail. */
accent?: string;
/** Fires with the new step index. */
onStepChange?: (step: number) => void;
};
type VariantConfig = {
/** How far a panel travels as it enters or leaves, in px. */
travel: number;
/** Seconds for the outgoing panel to clear. */
exit: number;
/** Seconds for a rail segment to fill. */
rail: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: panels are text, so they translate and fade and never
// scale, and their springs sit at or above a 0.8 damping ratio — a form
// field that overshoots its resting line and comes back is unreadable
// while it does it. The one thing that scales is the completion mark,
// which is a glyph. Variants change the distance and the pace of the
// rail, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A small shift. For a long internal flow that is stepped through
// quickly.
subtle: {
travel: 12,
exit: 0.1,
rail: 0.22,
spring: { type: "spring", stiffness: 620, damping: 46 },
},
// Enough travel to read the direction of the move. All-purpose.
default: {
travel: 22,
exit: 0.14,
rail: 0.32,
spring: { type: "spring", stiffness: 470, damping: 38 },
},
// Longer travel and a slower fill, so each step lands as an event —
// for a checkout or an account setup.
playful: {
travel: 34,
exit: 0.17,
rail: 0.42,
spring: { type: "spring", stiffness: 380, damping: 33 },
},
};
const ACCENT = "#7C7CF0";
/** The completion mark pops once and stops. Its own spring, because it is
* a glyph and can carry a little more life than the panels can. */
const MARK_SPRING = { type: "spring" as const, stiffness: 520, damping: 38 };
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or track that is
* correctly toned in either theme. The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Panel = { heading: string; body: string; fields: string[] };
type Step = Panel & { marker: string };
const STEPS: Step[] = [
{
marker: "Details",
heading: "Company details",
body: "Legal name, registration number and the address invoices are issued from.",
fields: ["Northwind Trading Ltd", "Registration 04812277"],
},
{
marker: "Billing",
heading: "Billing contact",
body: "Where invoices are sent and who to chase when one is overdue.",
fields: ["billing@northwind.example", "Net 30 terms"],
},
{
marker: "Review",
heading: "Review and confirm",
body: "Nothing is charged until this is confirmed. Everything here can be edited later.",
fields: ["Team plan · 24 seats", "$1,240.00 per month"],
},
];
const DONE_PANEL: Panel = {
heading: "Billing is set up",
body: "The first invoice will be issued on the first of next month.",
fields: ["Team plan · 24 seats", "Next invoice 1 October"],
};
export default function WizardStepAdvance({
variant = "default",
defaultStep = 0,
accent = ACCENT,
onStepChange,
}: WizardStepAdvanceProps) {
const [step, setStep] = useState(defaultStep);
const [direction, setDirection] = useState(1);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const finished = step >= STEPS.length;
const panel = finished ? DONE_PANEL : STEPS[step];
const goTo = (next: number) => {
if (next < 0 || next > STEPS.length) return;
setDirection(next > step ? 1 : -1);
setStep(next);
onStepChange?.(next);
};
// Reduced motion: the rail still fills and the panel still changes —
// the panel simply cross-fades instead of travelling.
const travel = reduceMotion ? 0 : cfg.travel;
const panelVariants = {
enter: (dir: number) => ({ x: dir * travel, opacity: 0 }),
center: {
x: 0,
opacity: 1,
transition: reduceMotion
? { duration: 0.14, ease: "easeOut" as const }
: { ...cfg.spring, opacity: { duration: 0.18, ease: "easeOut" as const } },
},
exit: (dir: number) => ({
x: -dir * travel,
opacity: 0,
transition: { duration: reduceMotion ? 0.1 : cfg.exit, ease: "easeIn" as const },
}),
};
const railTransition = { duration: reduceMotion ? 0 : cfg.rail, ease: "easeOut" as const };
const markTransition = reduceMotion ? { duration: 0 } : MARK_SPRING;
return (
<div
style={{
width: 336,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div style={{ padding: "16px 18px 12px" }}>
<div style={{ fontSize: 11, opacity: 0.5, letterSpacing: "0.05em" }}>
{finished ? "COMPLETE" : `STEP ${step + 1} OF ${STEPS.length}`}
</div>
<div
style={{
display: "flex",
alignItems: "center",
marginTop: 12,
}}
>
{STEPS.map((entry, index) => {
const complete = index < step;
const current = index === step;
return (
<div
key={entry.marker}
style={{
display: "flex",
alignItems: "center",
flex: index === STEPS.length - 1 ? "0 0 auto" : 1,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 5,
width: 52,
}}
>
<span
aria-hidden
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 24,
height: 24,
borderRadius: 12,
background: tone(8),
border: `1px solid ${tone(14)}`,
}}
>
{/* The ring marks "you are here". It fades in place
rather than travelling from the previous step: the
panel is already moving, and two moving objects
would split the glance. */}
<motion.span
initial={false}
animate={{ opacity: current ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.18, ease: "easeOut" }}
style={{
position: "absolute",
inset: -2,
borderRadius: 14,
border: `2px solid ${accent}`,
}}
/>
<motion.span
initial={false}
animate={{ opacity: complete ? 1 : 0, scale: complete ? 1 : 0.7 }}
transition={markTransition}
style={{
position: "absolute",
inset: -1,
borderRadius: 13,
background: accent,
}}
/>
{/* The numeral only fades. It is text, and text that
changes size on a step change is the tell. */}
<motion.span
initial={false}
animate={{ opacity: complete ? 0 : 1 }}
transition={{ duration: reduceMotion ? 0 : 0.14, ease: "easeOut" }}
style={{
position: "relative",
fontSize: 10.5,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
opacity: current ? 1 : 0.55,
}}
>
{index + 1}
</motion.span>
<motion.svg
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
stroke="#ffffff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ opacity: complete ? 1 : 0, scale: complete ? 1 : 0.6 }}
transition={markTransition}
style={{ position: "absolute" }}
>
<path d="m3.4 7.3 2.4 2.5 4.8-5.1" />
</motion.svg>
</span>
<span
style={{
fontSize: 10,
fontWeight: 600,
opacity: current || complete ? 0.85 : 0.42,
}}
>
{entry.marker}
</span>
</div>
{index < STEPS.length - 1 && (
<div
aria-hidden
style={{
flex: 1,
height: 2,
marginBottom: 18,
borderRadius: 1,
background: tone(12),
overflow: "hidden",
}}
>
{/* scaleX from the left: the segment fills toward the
step being entered, which is the same direction the
panel travels. */}
<motion.div
initial={false}
animate={{ scaleX: index < step ? 1 : 0 }}
transition={railTransition}
style={{
height: "100%",
background: accent,
transformOrigin: "0% 50%",
}}
/>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Fixed height: a panel that resizes mid-move fights its own
horizontal travel and makes the buttons below hop. */}
<div
style={{
position: "relative",
height: 132,
borderTop: `1px solid ${tone(10)}`,
overflow: "hidden",
}}
>
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.div
key={finished ? "done" : step}
custom={direction}
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
style={{
position: "absolute",
inset: 0,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 14.5, fontWeight: 650 }}>{panel.heading}</div>
<p
style={{
margin: "5px 0 10px",
fontSize: 12,
lineHeight: 1.5,
opacity: 0.6,
}}
>
{panel.body}
</p>
{panel.fields.map((field) => (
<div
key={field}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
marginBottom: 6,
borderRadius: 9,
background: tone(7),
border: `1px solid ${tone(10)}`,
fontSize: 12,
}}
>
{field}
</div>
))}
</motion.div>
</AnimatePresence>
</div>
<div
style={{
display: "flex",
gap: 8,
padding: "12px 18px 16px",
borderTop: `1px solid ${tone(10)}`,
}}
>
<button
type="button"
onClick={() => goTo(step - 1)}
disabled={step === 0}
style={{
padding: "9px 14px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: `1px solid ${tone(14)}`,
background: "transparent",
color: "inherit",
opacity: step === 0 ? 0.35 : 1,
cursor: step === 0 ? "default" : "pointer",
}}
>
Back
</button>
<button
type="button"
onClick={() => goTo(finished ? 0 : step + 1)}
style={{
flex: 1,
padding: "9px 14px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: 0,
background: accent,
color: "#ffffff",
cursor: "pointer",
}}
>
{finished
? "Start over"
: step === STEPS.length - 1
? "Confirm and finish"
: "Continue"}
</button>
</div>
</div>
);
}About this pattern
A multi-part flow where the marker and the content are saying the same thing at the same time: the rail segment fills from the left toward the step being entered, the marker behind it takes its completion mark, and the panel travels in that same direction — reversed exactly when you go back. Agreement is the whole point, because a header that advances while the body slides the other way is how a flow starts feeling arbitrary. The panels are text, so they translate and fade and never scale, on springs damped hard enough that no field overshoots its resting line. The one thing that pops is the completion mark, which is a glyph and can carry a little more life than a paragraph can. The body is a fixed height so the buttons underneath never hop.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
A row of stages that fills as the buyer moves through information, shipping and payment.
Related patterns
- Checkout Step ProgressThe finished stage folds into a one-line recap while the following one opens and the rail fills.
- Bottom Sheet PresentA sheet rises from the bottom edge over a dimming backdrop and settles once.
- Drawer Slide InA side drawer travels in over the page, and the page eases back a little to hand it the foreground.