EmpireUI
Get Pro
← Blog9 min read#stepper#wizard#progress

Stepper / Wizard Progress in React: Linear, Non-Linear, Branching

Build steppers and wizard flows in React that actually handle real-world complexity — linear paths, skippable steps, and branching logic all covered.

Code editor screen showing React component architecture for multi-step wizard

Why Steppers Are Harder Than They Look

You've built a form before. You've probably also built a multi-step form and immediately regretted not planning it better. Steppers look simple from the outside — a numbered list, a few buttons, maybe a progress bar. Then the product manager asks for conditional branching based on user type, and suddenly you're debugging state that lives in three different components.

The problem isn't React. It's that most stepper tutorials show you the happy path: step 1, step 2, step 3, done. What they skip is everything that actually happens in production: users navigating backwards, optional steps, steps that appear or disappear based on earlier answers, and async validation that has to complete before proceeding.

This article covers all three patterns you'll actually encounter — linear, non-linear, and branching — with real code you can adapt. If you've been using a library that wraps everything in magic abstractions and breaks the moment you need something slightly custom, this is the escape hatch.

Quick aside: if you're pairing your stepper with a polished UI, check out the Empire UI component library. The aesthetic you choose for the surrounding UI matters — a brutalist stepper feels completely different from one wrapped in glassmorphism components.

The Linear Stepper: State Machine Basics

Linear is the baseline. Step N completes, you move to step N+1, and the user can't skip. This is your onboarding flow, your checkout, your account setup. It sounds trivial to implement but there are a few decisions that bite you if you make them wrong early.

The most important decision: where does step state live? Don't scatter it across individual step components. Centralize everything in a parent useStepper hook from day one. You'll thank yourself the first time you need to read step-3 data from step-5 validation.

// useStepper.ts
import { useState, useCallback } from 'react';

type StepStatus = 'pending' | 'active' | 'complete' | 'error';

interface Step {
  id: string;
  label: string;
  status: StepStatus;
}

export function useStepper(initialSteps: Omit<Step, 'status'>[]) {
  const [steps, setSteps] = useState<Step[]>(
    initialSteps.map((s, i) => ({
      ...s,
      status: i === 0 ? 'active' : 'pending',
    }))
  );
  const [currentIndex, setCurrentIndex] = useState(0);

  const goNext = useCallback(() => {
    setSteps((prev) =>
      prev.map((s, i) => {
        if (i === currentIndex) return { ...s, status: 'complete' };
        if (i === currentIndex + 1) return { ...s, status: 'active' };
        return s;
      })
    );
    setCurrentIndex((i) => i + 1);
  }, [currentIndex]);

  const goBack = useCallback(() => {
    setSteps((prev) =>
      prev.map((s, i) => {
        if (i === currentIndex) return { ...s, status: 'pending' };
        if (i === currentIndex - 1) return { ...s, status: 'active' };
        return s;
      })
    );
    setCurrentIndex((i) => i - 1);
  }, [currentIndex]);

  return { steps, currentIndex, goNext, goBack };
}

That StepStatus type is doing a lot of work. error lets you mark a step as invalid when the user tries to proceed without completing it — way more useful than just showing a toast. Wire goNext to your form's submit handler and run validation there before calling it.

Worth noting: if your steps have async validation (API call to check email uniqueness, for example), you'll want to add an isLoading flag to the hook and disable the Next button while the request is in flight. Don't let users double-submit.

Non-Linear Navigation: Letting Users Jump Around

Non-linear steppers let users click any previously-completed step and return to it. This is a UX pattern that significantly reduces frustration in longer flows — anything past 4 steps where a user might realize they entered their email wrong back on step 2.

The trick is tracking which steps are "accessible" vs "locked". A step becomes accessible once it's been completed. If you're strict about it, you only allow backward jumps, not forward skips. If you're lenient, completed steps can be revisited in any order.

// In useStepper, add a goTo function
const goTo = useCallback(
  (targetIndex: number) => {
    const targetStep = steps[targetIndex];
    // Only allow navigation to complete or active steps
    if (targetStep.status === 'pending') return;

    setSteps((prev) =>
      prev.map((s, i) => {
        if (i === currentIndex && i !== targetIndex)
          return { ...s, status: 'complete' };
        if (i === targetIndex) return { ...s, status: 'active' };
        return s;
      })
    );
    setCurrentIndex(targetIndex);
  },
  [currentIndex, steps]
);

In practice, the click handler on your step indicator becomes the UX differentiator. Make it visually obvious which steps are clickable. A cursor change (pointer vs default), a slightly different opacity at 60% for locked steps, and a checkmark icon on completed ones — those 3px details are what separate a professional stepper from a tutorial one.

One more thing — if you're building this for a longer checkout or settings flow, consider URL sync. Storing the current step index in a query param (?step=2) means users can refresh without losing their place and you can deep-link to specific steps from support emails.

Branching Logic: When Steps Depend on Earlier Answers

This is where most stepper implementations fall apart. A user picks "Business" instead of "Personal" and suddenly they need three different steps. Or they say they have a VAT number and a whole tax section appears. This isn't exotic — it's basically every real-world onboarding flow built since 2018.

The key insight: stop thinking of your steps as a fixed array. Make them derived from state. Your steps variable should be computed from the user's answers, not hardcoded.

// Define all possible steps
const ALL_STEPS = {
  account: { id: 'account', label: 'Account' },
  personal: { id: 'personal', label: 'Personal Info' },
  business: { id: 'business', label: 'Business Info' },
  billing: { id: 'billing', label: 'Billing' },
  vatDetails: { id: 'vatDetails', label: 'VAT Details' },
  confirm: { id: 'confirm', label: 'Confirm' },
} as const;

// Derive visible steps from form state
function getVisibleSteps(formData: Partial<OnboardingForm>) {
  const steps = [ALL_STEPS.account];

  if (formData.accountType === 'personal') {
    steps.push(ALL_STEPS.personal);
  } else if (formData.accountType === 'business') {
    steps.push(ALL_STEPS.business);
    if (formData.hasVat) {
      steps.push(ALL_STEPS.vatDetails);
    }
  }

  steps.push(ALL_STEPS.billing, ALL_STEPS.confirm);
  return steps;
}

// Inside your component
const visibleSteps = useMemo(
  () => getVisibleSteps(formData),
  [formData.accountType, formData.hasVat]
);

The currentIndex now refers to an index in visibleSteps, which changes. This means you need to be careful when form data changes cause a step the user is currently on to disappear. Add a guard: if currentIndex >= visibleSteps.length, clamp it to visibleSteps.length - 1.

Honestly, once you've built two or three branching flows, you start reaching for a proper state machine. XState is overkill for most forms but it's genuinely the right tool when you have branching that has branching that has branching. For simpler cases, derived steps with useMemo keeps things readable without adding a dependency.

Building the Progress Indicator UI

The stepper indicator — those circles or numbers at the top — is often underestimated in complexity. It needs to handle variable labels, overflow on mobile, RTL, and a range of states all at once. Let's build one that doesn't embarrass you in production.

interface StepIndicatorProps {
  steps: Array<{ id: string; label: string; status: StepStatus }>;
  onStepClick?: (index: number) => void;
}

export function StepIndicator({ steps, onStepClick }: StepIndicatorProps) {
  return (
    <nav aria-label="Progress" className="flex items-center gap-0">
      {steps.map((step, index) => (
        <div key={step.id} className="flex items-center">
          <button
            onClick={() => onStepClick?.(index)}
            disabled={step.status === 'pending'}
            aria-current={step.status === 'active' ? 'step' : undefined}
            className={[
              'flex h-9 w-9 items-center justify-center rounded-full border-2 text-sm font-semibold transition-all duration-200',
              step.status === 'complete'
                ? 'border-violet-600 bg-violet-600 text-white cursor-pointer'
                : step.status === 'active'
                ? 'border-violet-600 bg-white text-violet-600'
                : step.status === 'error'
                ? 'border-red-500 bg-red-50 text-red-600'
                : 'border-gray-200 bg-white text-gray-400 cursor-not-allowed',
            ].join(' ')}
          >
            {step.status === 'complete' ? (
              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
              </svg>
            ) : step.status === 'error' ? (
              '!'
            ) : (
              index + 1
            )}
          </button>
          {index < steps.length - 1 && (
            <div
              className={[
                'h-0.5 w-12 transition-colors duration-300',
                index < steps.findIndex((s) => s.status === 'active')
                  ? 'bg-violet-600'
                  : 'bg-gray-200',
              ].join(' ')}
            />
          )}
        </div>
      ))}
    </nav>
  );
}

A few things worth calling out in that code. The aria-current="step" attribute is what screen readers use to announce the current position. The connector line between steps changes color as steps are completed — that 300ms transition-colors is subtle but it's the kind of thing users notice subconsciously.

On mobile, the label text under each step becomes a problem past 4 steps. You have two options: hide labels entirely below a certain breakpoint (just show the numbered circles), or switch to a horizontal scrollable strip. Both are valid. Pick based on how important the label text is to your specific flow.

Look, the stepper UI is also where you can make something that genuinely looks great rather than generic. If you're going for a dark theme, the glassmorphism generator can give you a frosted card background for the active step that looks significantly better than a flat white box. The step connector lines work particularly well with a subtle gradient.

Async Validation and Error Handling Between Steps

The "Next" button is a lie if you're not running validation before advancing. Synchronous field-level validation is handled by your form library — React Hook Form, Formik, whatever you're using. But step-level validation, especially async, is your responsibility.

// Async validation before advancing
async function handleNext() {
  // Run field validation first (React Hook Form example)
  const isValid = await trigger(currentStepFields);
  if (!isValid) return;

  // Async check — e.g. email availability
  if (currentIndex === 0) {
    setIsValidating(true);
    try {
      const available = await checkEmailAvailability(formData.email);
      if (!available) {
        setError('email', { message: 'This email is already registered' });
        setIsValidating(false);
        return;
      }
    } catch {
      // Don't block progress on API failure — degrade gracefully
      console.warn('Email check failed, proceeding anyway');
    } finally {
      setIsValidating(false);
    }
  }

  goNext();
}

Notice the error catch block — it proceeds on API failure rather than blocking the user. That's a deliberate product decision and you should make it explicitly rather than by accident. Discuss it with your team. In most cases, validating on the server at final submission is the right safety net; blocking progress due to a transient network error is a bad user experience.

In practice, the stepper state and the form state need to be linked but not entangled. Your useStepper hook shouldn't know what React Hook Form is. Pass a validation function in as a callback instead of coupling them directly. This keeps the stepper reusable and testable in isolation.

If you're building a complex onboarding flow, consider persisting partial form data to localStorage or server-side after each step. Users abandon multi-step forms at a rate that will surprise you. Saving progress after step 2 and restoring it on return is an easy win that most teams don't bother with.

Accessibility, Animation, and the Finishing Touches

A stepper without proper ARIA support is going to fail an accessibility audit — and given WCAG 2.2 was finalized in 2023, there's no excuse for not meeting AA compliance. The key attributes: aria-label on the nav, aria-current="step" on the active step button, aria-disabled on locked ones, and aria-live on the step content area so screen readers announce when content changes.

// Step content area with live region
<div
  role="region"
  aria-live="polite"
  aria-atomic="true"
  aria-label={`Step ${currentIndex + 1}: ${currentStep.label}`}
>
  {renderCurrentStep()}
</div>

For step transitions, keep animations fast and direction-aware. If the user clicks back, the content should slide right (out of the way). If they go forward, it should slide left. Framer Motion makes this dead simple with a direction-keyed AnimatePresence.

const direction = useRef(1); // 1 = forward, -1 = backward

// In your goNext / goBack, set direction.current before calling setCurrentIndex

<AnimatePresence mode="wait" custom={direction.current}>
  <motion.div
    key={currentIndex}
    custom={direction.current}
    initial={{ opacity: 0, x: direction.current * 40 }}
    animate={{ opacity: 1, x: 0 }}
    exit={{ opacity: 0, x: direction.current * -40 }}
    transition={{ duration: 0.2, ease: 'easeInOut' }}
  >
    {renderCurrentStep()}
  </motion.div>
</AnimatePresence>

One more thing — test your stepper with a keyboard only. Tab to the Next button, submit the form, verify focus lands somewhere sensible on the new step. Focus management in multi-step flows is one of those things that looks fine with a mouse and is completely broken for keyboard users. A useEffect that focuses the step heading on step change handles this for most cases. And if you're looking for more component patterns to pair with your stepper — tooltips, modals, progress bars — browse components to see what fits your stack.

FAQ

Should I use a library like react-stepper-horizontal or build my own?

Build your own for anything non-trivial. Most stepper libraries don't handle branching or async validation well, and you'll spend more time fighting the abstraction than writing the logic yourself. The code in this article is a solid starting point.

How do I keep step data between steps without prop drilling?

A single useStepper hook with a shared formData object covers most cases. For larger flows, React Context or a lightweight store like Zustand keeps things clean without Redux-level overhead.

How do I validate only the fields in the current step with React Hook Form?

Use the trigger function with an array of field names relevant to the current step. Call trigger(['email', 'password']) before goNext() to validate just those fields without touching the rest of the form.

What's the best way to handle a back button in a branching stepper?

Go back to currentIndex - 1 in your visibleSteps array, not in the full step list. Since visible steps are derived from form state, the previous visible step is always the correct destination regardless of which branch the user is on.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Multi-Step Form in React: Progress, Validation, State PersistenceSurvey Form in React: Multi-Step, Progress, Scale and Multiple ChoiceGlassmorphism Onboarding UI: Multi-Step Wizard With Frosted StepsScroll Progress Indicator in React: Bar, Circle and Sidebar Dots