This guide walks you through building a production-ready, animated multi-step onboarding flow using Framer Motion 11 and React 19. By the end, you'll have a fully functional onboarding wizard with smooth step transitions, progress tracking, and form validation — built in roughly 2–3 hours.
What You'll Build
- A multi-step onboarding wizard with animated forward and backward transitions between steps
- A live progress indicator that reflects the user's current position in the flow
- Per-step form validation using React Hook Form and Zod, preventing users from advancing with incomplete data
- An exit animation and success screen that plays once all steps are complete
- A reusable
OnboardingStepcomponent you can drop into any React project
Prerequisites
- Node.js 20+ and pnpm 9 (or npm 10+)
- A working React 19 project — Next.js 15 with the App Router works perfectly
- Basic familiarity with TypeScript, React hooks, and Tailwind CSS
- Framer Motion 11, React Hook Form 7.5+, and Zod 3.23+ installed
Install the required packages before starting:
pnpm add framer-motion react-hook-form zod @hookform/resolvers
Step 1: Plan Your Step Architecture
Why does step architecture matter before writing a single line?
A poorly planned step structure causes state management chaos later. Define each step as a typed object upfront so your data model stays consistent across the entire flow.
Create a types/onboarding.ts file:
export type OnboardingStep = {
id: string;
title: string;
description: string;
};
export type OnboardingFormData = {
name: string;
email: string;
role: string;
teamSize: string;
goal: string;
};
export const STEPS: OnboardingStep[] = [
{ id: 'profile', title: 'Your Profile', description: 'Tell us who you are' },
{ id: 'role', title: 'Your Role', description: 'Help us personalise your experience' },
{ id: 'goal', title: 'Your Goal', description: 'What do you want to achieve?' },
];
Expected result: A typed step registry that every component in your flow imports from a single source of truth.
Common pitfall: Storing step definitions inside the component. This makes unit testing impossible and causes prop-drilling nightmares.
Step 2: Build the Step State Machine
What state does the onboarding flow actually need?
You need three pieces of state: the current step index, the direction of travel (forward or backward, used by Framer Motion), and the accumulated form data. Use a useReducer pattern rather than multiple useState calls — it keeps transitions atomic and predictable.
Create hooks/useOnboarding.ts:
import { useReducer } from 'react';
import { OnboardingFormData, STEPS } from '@/types/onboarding';
type State = {
currentStep: number;
direction: 1 | -1;
data: Partial;
completed: boolean;
};
type Action =
| { type: 'NEXT'; payload: Partial }
| { type: 'PREV' }
| { type: 'COMPLETE'; payload: Partial };
const initialState: State = {
currentStep: 0,
direction: 1,
data: {},
completed: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'NEXT':
return {
...state,
currentStep: Math.min(state.currentStep + 1, STEPS.length - 1),
direction: 1,
data: { ...state.data, ...action.payload },
};
case 'PREV':
return {
...state,
currentStep: Math.max(state.currentStep - 1, 0),
direction: -1,
};
case 'COMPLETE':
return {
...state,
data: { ...state.data, ...action.payload },
completed: true,
};
default:
return state;
}
}
export function useOnboarding() {
const [state, dispatch] = useReducer(reducer, initialState);
const isFirst = state.currentStep === 0;
const isLast = state.currentStep === STEPS.length - 1;
return { state, dispatch, isFirst, isLast };
}
Expected result: A hook that any component can consume. The direction value is critical — Framer Motion will read it to decide whether panels slide left or right.
Step 3: Define the Animation Variants
Which Framer Motion pattern works best for stepped flows?
Use AnimatePresence with custom prop variants. This lets each step know which direction it's entering or exiting from. The key insight: Framer Motion 11's layout animations are ~30% more performant than version 10 thanks to its new hybrid WAAPI/JS engine — use layout on your progress bar for a free smooth resize.
Create lib/variants.ts:
import { Variants } from 'framer-motion';
export const stepVariants: Variants = {
enter: (direction: number) => ({
x: direction > 0 ? '60%' : '-60%',
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
transition: { duration: 0.35, ease: [0.32, 0.72, 0, 1] },
},
exit: (direction: number) => ({
x: direction > 0 ? '-60%' : '60%',
opacity: 0,
transition: { duration: 0.25, ease: [0.32, 0.72, 0, 1] },
}),
};
Pro tip: The cubic bezier [0.32, 0.72, 0, 1] is Apple's standard easing curve. It reads as intentional and premium — far better than a linear or ease-in-out default.
Step 4: Build the Progress Indicator
A clear progress indicator reduces abandonment. According to UX research published in 2025, multi-step forms with visible progress indicators complete at rates 28–35% higher than those without one.
Create components/StepProgress.tsx:
import { motion } from 'framer-motion';
import { STEPS } from '@/types/onboarding';
export function StepProgress({ current }: { current: number }) {
const percentage = ((current + 1) / STEPS.length) * 100;
return (
Step {current + 1} of {STEPS.length}
{STEPS[current].title}
);
}
Expected result: A smooth animated progress bar that grows as users advance through steps.
Step 5: Build Individual Step Forms
How do you validate each step independently without a monolithic form?
Give each step its own Zod schema and its own useForm instance. This is cleaner than one giant form and lets you validate only the fields visible in the current step.
// components/steps/ProfileStep.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Enter a valid email address'),
});
type ProfileData = z.infer;
type Props = {
defaultValues: Partial;
onNext: (data: ProfileData) => void;
};
export function ProfileStep({ defaultValues, onNext }: Props) {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues,
});
return (
);
}
Build RoleStep and GoalStep using the same pattern with their own schemas. Repeat the structure exactly — this consistency is what makes the flow feel coherent.
Step 6: Assemble the Onboarding Container
Now wire everything together. The AnimatePresence component must receive mode="wait" so the exiting step finishes its animation before the entering step begins.
// components/OnboardingFlow.tsx
import { AnimatePresence, motion } from 'framer-motion';
import { useOnboarding } from '@/hooks/useOnboarding';
import { StepProgress } from './StepProgress';
import { ProfileStep } from './steps/ProfileStep';
import { RoleStep } from './steps/RoleStep';
import { GoalStep } from './steps/GoalStep';
import { SuccessScreen } from './SuccessScreen';
import { stepVariants } from '@/lib/variants';
const stepComponents = [ProfileStep, RoleStep, GoalStep];
export function OnboardingFlow() {
const { state, dispatch, isFirst, isLast } = useOnboarding();
if (state.completed) return ;
const CurrentStep = stepComponents[state.currentStep];
const handleNext = (data: Record) => {
if (isLast) {
dispatch({ type: 'COMPLETE', payload: data });
} else {
dispatch({ type: 'NEXT', payload: data });
}
};
return (
{/* STEPS[state.currentStep].title */}
{!isFirst && (
)}
);
}
Common pitfall: Forgetting mode="wait" on AnimatePresence. Without it, enter and exit animations overlap, creating a jarring double-render flash.
Step 7: Build the Success Screen
The success screen is your highest-leverage animation moment. Users who reach completion are at peak engagement — a satisfying exit animation reinforces that feeling.
// components/SuccessScreen.tsx
import { motion } from 'framer-motion';
export function SuccessScreen({ data }: { data: Record }) {
return (
You're all set!
Welcome aboard. We'll take you to your dashboard now.
);
}
Step 8: Test Accessibility and Keyboard Navigation
What accessibility issues do animated flows typically break?
Focus management is the most common failure. When a new step renders, focus stays on the previous step's last element — which is now offscreen. Fix this with a useEffect that moves focus to the step container on each step change. Also add aria-live="polite" to your progress region so screen readers announce step changes.
// Add to OnboardingFlow.tsx
import { useEffect, useRef } from 'react';
const stepRef = useRef(null);
useEffect(() => {
stepRef.current?.focus();
}, [state.currentStep]);
// Add to the motion.div wrapper:
// ref={stepRef} tabIndex={-1} aria-label={STEPS[state.currentStep].title}
Run your flow through NVDA or VoiceOver before shipping. Teams at Lenka Studio run a quick accessibility smoke test on every interactive component before handoff — it takes 10 minutes and catches 80% of common issues.
Frequently Asked Questions
Does Framer Motion work with Next.js App Router and React Server Components?
Framer Motion requires client-side rendering. Mark any component that uses Framer Motion with 'use client' at the top of the file. Your step forms and animation wrappers will all need this directive when using the Next.js 15 App Router.
How do I persist onboarding progress if the user refreshes the page?
Sync your reducer state to sessionStorage inside a useEffect and rehydrate on mount. For longer flows, use a server-side session via your auth provider (Auth.js, Clerk, or Supabase Auth) so progress persists across devices.
What if the animation feels laggy on low-end Android devices?
Add style={{ willChange: 'transform, opacity' }} to the animated motion.div. Also reduce the translation distance from 60% to 40% — shorter travel distances render faster on GPU-constrained devices. Framer Motion 11's WAAPI engine handles most of the heavy lifting, but large viewport-percentage translations can still stutter.
Can I use this pattern with a backend API instead of local state?
Yes. Replace the COMPLETE dispatch with an async fetch or axios call inside a handleSubmit wrapper. Show a loading spinner on the submit button while the request is in flight, then dispatch COMPLETE on success. Handle API errors inline on the final step form.
How is this different from using a library like Formik Wizard or react-step-wizard?
Those libraries add significant bundle weight (react-step-wizard is ~12 KB minified) and abstract away the animation layer entirely. The approach in this guide uses only Framer Motion and React Hook Form — both of which you likely already have — giving you full control over transitions and validation with no additional dependencies.
Next Steps
You now have a working, accessible, animated multi-step onboarding flow that you can extend for any SaaS, mobile app, or web platform. A few directions to take this further:
- Add analytics events — fire a tracking event on each step completion using PostHog or Segment to measure where users drop off
- Connect to your CRM — pipe the final
OnboardingFormDatapayload to HubSpot or Salesforce via a server action - Personalise post-onboarding content — use the
goalandrolefields to conditionally render different dashboard states on first login - A/B test step order — use PostHog feature flags to test whether asking for
rolebeforeprofileimproves completion rates in your specific audience
If you're building a product that needs to convert users from day one, onboarding design is one of the highest-ROI investments you can make. The team at Lenka Studio works with SaaS products, marketplaces, and apps across Australia, Singapore, Canada, and the US to design and build onboarding flows that retain users — not just acquire them. If you'd like a second set of eyes on your onboarding UX or need help implementing something more complex, get in touch with us — we're happy to review what you're building.




