Onboarding Step Transition
The next panel enters from the right as the current one leaves left, with the progress bar advancing behind them.
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 · Onboarding Step Transition
*
* The next panel slides in from the right while the current one leaves
* to the left, and the progress bar advances underneath them.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `steps`, `accent`. Sample steps are
* embedded so the file runs as-is.
* Requires the automatic JSX runtime (default since React 17).
*/
export type OnboardingStep = {
title: string;
body: string;
};
export type OnboardingStepTransitionProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your own steps. The embedded sample is used when omitted. */
steps?: OnboardingStep[];
/** Label of the primary button on the last step. */
finishLabel?: string;
/** Progress bar and primary button color. */
accent?: string;
/** Fires with the index the flow just moved to. */
onStepChange?: (index: number) => void;
/** Fires when the last step is confirmed. */
onFinish?: () => void;
};
type VariantConfig = {
/** How far a panel travels on the way in and out, in px. */
travel: number;
panelSpring: { type: "spring"; stiffness: number; damping: number };
progressSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: panels are mostly text, so every spring here sits at or
// above a 0.8 damping ratio — a heading that rebounds after it lands
// makes the whole flow feel cheap. Variants change how far a panel
// travels and how fast it gets there, never how much it wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Short travel, no settle. For long flows where the user will see
// this transition eight times in a row.
subtle: {
travel: 20,
panelSpring: { type: "spring", stiffness: 520, damping: 48 },
progressSpring: { type: "spring", stiffness: 300, damping: 36 },
},
// Enough travel to establish direction. The all-purpose setting.
default: {
travel: 28,
panelSpring: { type: "spring", stiffness: 420, damping: 40 },
progressSpring: { type: "spring", stiffness: 240, damping: 30 },
},
// Wider travel and a looser bar — the flow feels like pages turning.
playful: {
travel: 36,
panelSpring: { type: "spring", stiffness: 380, damping: 32 },
progressSpring: { type: "spring", stiffness: 220, damping: 26 },
},
};
const SAMPLE_STEPS: OnboardingStep[] = [
{
title: "Create your workspace",
body: "Pick a name and a URL. Both can be changed later from settings, so anything works for now.",
},
{
title: "Invite your team",
body: "Add teammates by email and they land in the right projects on their first day.",
},
{
title: "Connect your tools",
body: "Sync calendar, storage and issue tracking so everything shows up in one place.",
},
];
/** Fixed so the card cannot resize mid-slide — a jumping frame fights
* the horizontal movement and makes both read as sloppy. */
const PANEL_HEIGHT = 132;
export default function OnboardingStepTransition({
variant = "default",
steps = SAMPLE_STEPS,
finishLabel = "Get started",
accent = "#5B5BD6",
onStepChange,
onFinish,
}: OnboardingStepTransitionProps) {
// Direction is stored alongside the index because the exit animation
// has to know which way the flow was heading, not where it ended up.
const [[index, direction], setState] = useState<[number, number]>([0, 1]);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const isLast = index === steps.length - 1;
const go = (nextIndex: number, nextDirection: number) => {
setState([nextIndex, nextDirection]);
onStepChange?.(nextIndex);
};
const handleNext = () => {
if (isLast) {
onFinish?.();
// The sample loops back to the start so the flow stays replayable;
// in a real app this is where onboarding hands off.
go(0, 1);
return;
}
go(index + 1, 1);
};
// Reduced motion: the panels still replace each other, they just
// cross-fade in place instead of travelling.
const panelVariants = reduceMotion
? {
enter: { opacity: 0 },
center: {
opacity: 1,
transition: { duration: 0.18, ease: "easeOut" as const },
},
exit: {
opacity: 0,
transition: { duration: 0.12, ease: "easeOut" as const },
},
}
: {
enter: (dir: number) => ({ x: dir * cfg.travel, opacity: 0 }),
center: {
x: 0,
opacity: 1,
transition: {
...cfg.panelSpring,
// The fade is shorter than the slide, so the incoming panel
// is readable before it finishes arriving.
opacity: { duration: 0.2, ease: "easeOut" as const },
},
},
exit: (dir: number) => ({
x: -dir * cfg.travel,
opacity: 0,
transition: {
...cfg.panelSpring,
opacity: { duration: 0.14, ease: "easeOut" as const },
},
}),
};
return (
<div
style={{
width: 320,
padding: 20,
borderRadius: 18,
border: "1px solid rgba(127,127,140,0.22)",
background: "rgba(127,127,140,0.07)",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 11.5,
fontWeight: 600,
letterSpacing: 0.2,
opacity: 0.5,
marginBottom: 8,
}}
>
<span>Set up</span>
{/* Plain text swap: a counter that animates its own digits would
pull attention away from the panel that is actually moving. */}
<span>{`Step ${index + 1} of ${steps.length}`}</span>
</div>
<div
aria-hidden
style={{
position: "relative",
height: 3,
borderRadius: 3,
background: "rgba(127,127,140,0.24)",
overflow: "hidden",
}}
>
<motion.div
animate={{ scaleX: (index + 1) / steps.length }}
initial={{ scaleX: 1 / steps.length }}
transition={reduceMotion ? { duration: 0 } : cfg.progressSpring}
style={{
position: "absolute",
inset: 0,
borderRadius: 3,
background: accent,
// scaleX from the left edge rather than an animated width:
// width is a layout property and would relayout every frame.
transformOrigin: "0% 50%",
}}
/>
</div>
<div
style={{
position: "relative",
height: PANEL_HEIGHT,
marginTop: 16,
}}
>
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={index}
custom={direction}
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
style={{ position: "absolute", inset: 0 }}
>
<div
style={{
fontSize: 17,
fontWeight: 650,
lineHeight: 1.3,
}}
>
{steps[index].title}
</div>
<p
style={{
margin: "8px 0 0",
fontSize: 13.5,
lineHeight: 1.6,
opacity: 0.6,
}}
>
{steps[index].body}
</p>
</motion.div>
</AnimatePresence>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => go(Math.max(0, index - 1), -1)}
disabled={index === 0}
style={{
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: "1px solid rgba(127,127,140,0.28)",
borderRadius: 9,
opacity: index === 0 ? 0.35 : 1,
cursor: index === 0 ? "default" : "pointer",
}}
>
Back
</button>
<button
type="button"
onClick={handleNext}
style={{
flex: 1,
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
cursor: "pointer",
}}
>
{isLast ? finishLabel : "Continue"}
</button>
</div>
</div>
);
}About this pattern
Paging through a setup flow, where the direction of travel is the message: forward sends the old panel left and brings the new one in from the right, back reverses both. The progress bar advances at the same time, so the user always knows how much is left without reading a counter. The card height is fixed and the panels are absolutely positioned, because a frame that resizes mid-slide fights the horizontal movement. Panels are mostly text, so they translate and fade only — never scale, never rebound.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Setup questions paged horizontally with a progress indicator above.
Related patterns
- Setup CompleteThe last item checks itself off, the checklist clears, and a single confirmation takes its place.
- Name Your WorkspaceTyping a name rolls the monogram to its new initial and slides a fresh address under the header it will appear in.
- Persona SelectPicking a role lifts that card while the others step back, and a line below says what the answer changed.