A good onboarding flow converts signups into activated users. Here is how to build one with step-by-step validation and smooth transitions.
The Wizard Hook
// hooks/use-wizard.ts
"use client";
import { useCallback, useState } from "react";
export interface WizardStep {
id: string;
title: string;
description?: string;
validate?: () => boolean | Promise<boolean>;
}
export function useWizard(steps: WizardStep[]) {
const [currentStep, setCurrentStep] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [direction, setDirection] = useState<"forward" | "backward">("forward");
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
const step = steps[currentStep];
const progress = ((currentStep + 1) / steps.length) * 100;
const goToStep = useCallback(
(index: number) => {
if (index < 0 || index >= steps.length) return;
setDirection(index > currentStep ? "forward" : "backward");
setCurrentStep(index);
},
[currentStep, steps.length]
);
const next = useCallback(async () => {
if (isLastStep) return;
// Run validation if present
if (step?.validate) {
const isValid = await step.validate();
if (!isValid) return;
}
setCompletedSteps((prev) => new Set(prev).add(currentStep));
setDirection("forward");
setCurrentStep((prev) => prev + 1);
}, [currentStep, isLastStep, step]);
const previous = useCallback(() => {
if (isFirstStep) return;
setDirection("backward");
setCurrentStep((prev) => prev - 1);
}, [isFirstStep]);
return {
currentStep,
step,
steps,
isFirstStep,
isLastStep,
completedSteps,
progress,
direction,
next,
previous,
goToStep,
};
}
Progress Indicator
"use client";
import type { WizardStep } from "@/hooks/use-wizard";
interface StepIndicatorProps {
steps: WizardStep[];
currentStep: number;
completedSteps: Set<number>;
onStepClick?: (index: number) => void;
}
export function StepIndicator({
steps,
currentStep,
completedSteps,
onStepClick,
}: StepIndicatorProps) {
return (
<nav aria-label="Onboarding progress">
<ol className="flex items-center w-full">
{steps.map((step, index) => {
const isCompleted = completedSteps.has(index);
const isCurrent = index === currentStep;
const isClickable = isCompleted || index <= currentStep;
return (
<li key={step.id} className="flex items-center flex-1 last:flex-none">
<button
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={`flex items-center gap-2 ${
isClickable ? "cursor-pointer" : "cursor-default"
}`}
aria-current={isCurrent ? "step" : undefined}
>
<div
className={`flex items-center justify-center h-8 w-8 rounded-full text-sm font-medium shrink-0 transition-colors ${
isCompleted
? "bg-green-500 text-white"
: isCurrent
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{isCompleted ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</div>
<span
className={`text-sm hidden sm:block ${
isCurrent ? "font-medium text-foreground" : "text-muted-foreground"
}`}
>
{step.title}
</span>
</button>
{/* Connector line */}
{index < steps.length - 1 && (
<div className="flex-1 mx-3">
<div
className={`h-0.5 ${
completedSteps.has(index)
? "bg-green-500"
: "bg-muted"
}`}
/>
</div>
)}
</li>
);
})}
</ol>
</nav>
);
}
Animated Step Content
"use client";
import { type ReactNode } from "react";
interface AnimatedStepProps {
direction: "forward" | "backward";
stepKey: string;
children: ReactNode;
}
export function AnimatedStep({ direction, stepKey, children }: AnimatedStepProps) {
return (
<div
key={stepKey}
className="animate-in duration-300"
style={{
animationName: direction === "forward" ? "slideInRight" : "slideInLeft",
}}
>
{children}
</div>
);
}
Add the CSS keyframes:
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
The Onboarding Page
"use client";
import { useState, useCallback } from "react";
import { useWizard, type WizardStep } from "@/hooks/use-wizard";
import { StepIndicator } from "@/components/onboarding/StepIndicator";
import { AnimatedStep } from "@/components/onboarding/AnimatedStep";
interface OnboardingData {
companyName: string;
industry: string;
teamSize: string;
goals: string[];
referralSource: string;
}
const initialData: OnboardingData = {
companyName: "",
industry: "",
teamSize: "",
goals: [],
referralSource: "",
};
export default function OnboardingPage() {
const [data, setData] = useState<OnboardingData>(initialData);
const update = useCallback(
<K extends keyof OnboardingData>(field: K, value: OnboardingData[K]) => {
setData((prev) => ({ ...prev, [field]: value }));
},
[]
);
const steps: WizardStep[] = [
{
id: "company",
title: "Company",
description: "Tell us about your company",
validate: () => data.companyName.length > 0 && data.industry.length > 0,
},
{
id: "team",
title: "Team",
description: "How big is your team?",
validate: () => data.teamSize.length > 0,
},
{
id: "goals",
title: "Goals",
description: "What do you want to achieve?",
validate: () => data.goals.length > 0,
},
{
id: "complete",
title: "Done",
description: "You are all set",
},
];
const wizard = useWizard(steps);
const handleComplete = async () => {
await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
window.location.href = "/dashboard";
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
<div className="w-full max-w-lg bg-background rounded-xl shadow-lg p-8">
<StepIndicator
steps={wizard.steps}
currentStep={wizard.currentStep}
completedSteps={wizard.completedSteps}
onStepClick={wizard.goToStep}
/>
{/* Progress bar */}
<div className="mt-6 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${wizard.progress}%` }}
/>
</div>
<div className="mt-8 min-h-[280px]">
<AnimatedStep
direction={wizard.direction}
stepKey={wizard.step?.id ?? ""}
>
{wizard.currentStep === 0 && (
<CompanyStep data={data} update={update} />
)}
{wizard.currentStep === 1 && (
<TeamStep data={data} update={update} />
)}
{wizard.currentStep === 2 && (
<GoalsStep data={data} update={update} />
)}
{wizard.currentStep === 3 && <CompleteStep data={data} />}
</AnimatedStep>
</div>
{/* Navigation */}
<div className="flex justify-between mt-8">
<button
onClick={wizard.previous}
disabled={wizard.isFirstStep}
className="px-4 py-2 text-sm border rounded-md disabled:opacity-0"
>
Back
</button>
{wizard.isLastStep ? (
<button
onClick={handleComplete}
className="px-6 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700"
>
Go to Dashboard
</button>
) : (
<button
onClick={wizard.next}
className="px-6 py-2 text-sm bg-primary text-primary-foreground rounded-md"
>
Continue
</button>
)}
</div>
</div>
</div>
);
}
Step Components
function CompanyStep({
data,
update,
}: {
data: OnboardingData;
update: <K extends keyof OnboardingData>(field: K, value: OnboardingData[K]) => void;
}) {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Tell us about your company</h2>
<div>
<label className="block text-sm font-medium mb-1">Company Name</label>
<input
type="text"
value={data.companyName}
onChange={(e) => update("companyName", e.target.value)}
className="w-full px-3 py-2 border rounded-md"
placeholder="Acme Inc."
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Industry</label>
<select
value={data.industry}
onChange={(e) => update("industry", e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background"
>
<option value="">Select an industry...</option>
<option value="technology">Technology</option>
<option value="healthcare">Healthcare</option>
<option value="finance">Finance</option>
<option value="education">Education</option>
<option value="retail">Retail</option>
<option value="other">Other</option>
</select>
</div>
</div>
);
}
function TeamStep({
data,
update,
}: {
data: OnboardingData;
update: <K extends keyof OnboardingData>(field: K, value: OnboardingData[K]) => void;
}) {
const options = ["1-5", "6-20", "21-50", "51-200", "200+"];
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">How big is your team?</h2>
<div className="grid grid-cols-1 gap-2">
{options.map((option) => (
<button
key={option}
onClick={() => update("teamSize", option)}
className={`px-4 py-3 border rounded-lg text-left transition-colors ${
data.teamSize === option
? "border-primary bg-primary/5"
: "hover:border-muted-foreground/30"
}`}
>
{option} people
</button>
))}
</div>
</div>
);
}
function GoalsStep({
data,
update,
}: {
data: OnboardingData;
update: <K extends keyof OnboardingData>(field: K, value: OnboardingData[K]) => void;
}) {
const goals = [
"Build a website",
"Improve SEO",
"Launch a mobile app",
"Redesign existing site",
"Build a web application",
"E-commerce store",
];
const toggleGoal = (goal: string) => {
const current = data.goals;
const next = current.includes(goal)
? current.filter((g) => g !== goal)
: [...current, goal];
update("goals", next);
};
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">What are your goals?</h2>
<p className="text-sm text-muted-foreground">Select all that apply</p>
<div className="grid grid-cols-2 gap-2">
{goals.map((goal) => (
<button
key={goal}
onClick={() => toggleGoal(goal)}
className={`px-3 py-2 border rounded-lg text-sm text-left transition-colors ${
data.goals.includes(goal)
? "border-primary bg-primary/5 font-medium"
: "hover:border-muted-foreground/30"
}`}
>
{goal}
</button>
))}
</div>
</div>
);
}
function CompleteStep({ data }: { data: OnboardingData }) {
return (
<div className="text-center space-y-4">
<div className="mx-auto h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold">You are all set!</h2>
<p className="text-muted-foreground">
Welcome aboard, {data.companyName}. Your workspace is ready.
</p>
</div>
);
}
Need Custom Onboarding Flows?
We build conversion-optimized onboarding experiences for SaaS and web applications. Contact us to improve your activation rates.