Skip to main content
Back to Blog
Tutorials
5 min read
December 11, 2024

How to Build a Multi-Step Onboarding Wizard in React

Create a polished multi-step onboarding wizard with progress tracking, validation, animations, and data persistence in React.

Ryel Banfield

Founder & Lead Developer

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.

onboardingwizardmulti-stepformsReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles