EmpireUI
Get Pro
← Blog8 min read#stepper#multi-step form#react

Stepper Component in React: Multi-Step Forms and Onboarding

Build a React stepper component for multi-step forms and onboarding flows. Covers state management, validation, and accessible UX patterns with code examples.

Abstract colorful gradient steps representing multi-step UI progress

Why Steppers Still Matter in 2026

Multi-step forms get a bad reputation. Developers avoid them because they're annoying to build, designers avoid them because they've seen too many botched implementations, and users avoid them because they've been burned by losing progress halfway through. That reputation is largely earned — but it's also fixable.

The stepper pattern exists for a reason. When you're collecting complex data — a checkout flow, a user onboarding sequence, a multi-page survey — dumping 20 fields onto one screen is objectively worse than breaking it into digestible chunks. Users complete shorter-looking forms at significantly higher rates. That's not opinion, that's a decade of conversion data.

Honestly, the problem isn't the pattern. It's that most stepper implementations are just a currentStep integer in state with some if-statements. There's no validation gating, no accessible focus management, no progress persistence. You can do a lot better with maybe 60 more lines of code.

If you're already browsing Empire UI, you probably care about component quality. This article is about building a stepper you'd actually want to maintain — not a demo that falls apart the moment a user hits the back button.

The Core State Model

Start here. Before you write a single JSX tag, decide what your stepper state actually looks like. A lot of bugs come from treating currentStep as the only thing that matters.

You need at minimum: the current step index, a record of which steps have been visited, and a per-step validation status. That's it. Don't over-engineer it on the first pass.

const initialState = {
  currentStep: 0,
  visited: new Set([0]),
  errors: {},
  formData: {},
};

function stepperReducer(state, action) {
  switch (action.type) {
    case 'NEXT':
      return {
        ...state,
        currentStep: state.currentStep + 1,
        visited: new Set([...state.visited, state.currentStep + 1]),
      };
    case 'PREV':
      return { ...state, currentStep: state.currentStep - 1 };
    case 'GO_TO':
      if (!state.visited.has(action.payload)) return state;
      return { ...state, currentStep: action.payload };
    case 'SET_DATA':
      return { ...state, formData: { ...state.formData, ...action.payload } };
    case 'SET_ERRORS':
      return { ...state, errors: { ...state.errors, [state.currentStep]: action.payload } };
    default:
      return state;
  }
}

Worth noting: using useReducer here instead of multiple useState calls keeps the logic testable and co-located. When you add validation gating later, you'll thank yourself for this structure.

The visited set is the thing most implementations skip. It's what lets you click a completed step header to jump back without letting users skip forward to steps they haven't unlocked yet. Small detail, big UX impact.

Building the Step Indicator

The step indicator is the horizontal (or vertical) progress bar at the top. It's visual affordance — it tells the user where they are, where they've been, and how far they have to go. Get this wrong and the whole thing feels broken.

Each step node should have three visual states: upcoming, active, and completed. Completed steps should be clickable (if you've visited them). Active has focus. Upcoming doesn't respond to clicks.

function StepIndicator({ steps, currentStep, visited, onStepClick }) {
  return (
    <nav aria-label="Form progress" className="stepper-nav">
      <ol className="stepper-list">
        {steps.map((step, index) => {
          const isCompleted = index < currentStep;
          const isActive = index === currentStep;
          const isVisited = visited.has(index);

          return (
            <li key={step.id} className="stepper-item">
              <button
                className={[
                  'stepper-button',
                  isActive ? 'active' : '',
                  isCompleted ? 'completed' : '',
                ].join(' ')}
                onClick={() => isVisited && onStepClick(index)}
                aria-current={isActive ? 'step' : undefined}
                disabled={!isVisited}
              >
                <span className="stepper-circle">
                  {isCompleted ? '✓' : index + 1}
                </span>
                <span className="stepper-label">{step.label}</span>
              </button>
              {index < steps.length - 1 && (
                <div
                  className={`stepper-connector ${isCompleted ? 'filled' : ''}`}
                  aria-hidden="true"
                />
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

The aria-current="step" attribute is doing quiet but important work here. Screen readers will announce the active step correctly. Pair that with disabled on unvisited steps and you've got accessible navigation without writing a custom ARIA role.

For the connector line between steps, a 2px line that fills with your accent color as steps complete is all you need visually. If you want something more elaborate, browse the components — there are some nice animated progress indicators worth adapting.

Validation Gating Between Steps

This is where most stepper implementations fall apart. You've got a Next button. Does it validate before advancing? When does it show errors — on blur, on submit attempt, or inline?

In practice, the best UX is: validate on Next attempt, show errors inline, don't advance until the step is clean. Don't validate on every keystroke (annoying) and don't wait until final submit (frustrating).

async function handleNext(validateStep, dispatch, currentStep) {
  const errors = await validateStep(currentStep);
  if (Object.keys(errors).length > 0) {
    dispatch({ type: 'SET_ERRORS', payload: errors });
    return;
  }
  dispatch({ type: 'NEXT' });
}

Keep validation functions per-step and async from the start — you might need server-side checks (email uniqueness, username availability) on step 1. If you wire it synchronous first, retrofitting async later is a pain.

One more thing — clear the errors for the current step when the user starts typing again. Nothing is more demoralizing than fixing a field and the error message stubbornly staying on screen.

Persisting Progress Across Reloads

Your users have cats. Browsers crash. Tabs get closed. If someone fills out two steps of your onboarding form and their laptop dies, you've already damaged trust. Persist to sessionStorage at minimum.

function usePersistentStepper(key, initialState) {
  const [state, dispatch] = useReducer(
    stepperReducer,
    initialState,
    (init) => {
      try {
        const saved = sessionStorage.getItem(key);
        if (saved) {
          const parsed = JSON.parse(saved);
          return { ...parsed, visited: new Set(parsed.visited) };
        }
      } catch {}
      return init;
    }
  );

  useEffect(() => {
    const serializable = {
      ...state,
      visited: [...state.visited],
    };
    sessionStorage.setItem(key, JSON.stringify(serializable));
  }, [state, key]);

  return [state, dispatch];
}

Note the visited: [...state.visited] serialization — Set doesn't JSON-serialize natively, so you spread it to an array and restore it in the initializer. Easy to miss, annoying to debug when you don't.

For longer onboarding flows where you want data to survive beyond the session, swap sessionStorage for localStorage and add an expiry timestamp. Seven days is a reasonable default for most onboarding scenarios.

That said, be careful with sensitive fields. If step 2 collects a password, don't persist it. Clear sensitive fields before writing to storage.

Styling Your Stepper

Steppers have a surprising number of moving parts visually: the indicator circles, connector lines, active/completed states, the step content area, navigation buttons. It adds up fast.

Look, you don't have to go minimal. Some of the best onboarding flows I've seen use rich visual feedback — animated checkmarks, color-coded steps, progress percentages. If you want to push the aesthetic, glassmorphism components can work really well for the step cards. Frosted glass panels with a clear progress indicator feel modern without being distracting.

Keep the active step's circle at least 40px in diameter. Below that, it starts feeling cramped on mobile, and the tap target becomes too small. The connector lines between steps want to be 2px — thicker looks chunky, thinner disappears on non-retina screens.

For the step content area, use a fixed min-height or animate between step heights with a CSS transition. Jumping between a tall step and a short step without any transition is jarring. Even a 200ms height animation (or a min-height that stays consistent) makes it feel polished.

Putting It Together: A Real-World Onboarding Flow

Here's how this lands in a real implementation. Say you're building a SaaS onboarding: step 1 collects account details, step 2 sets preferences, step 3 invites teammates, step 4 shows a completion screen. Each step is its own component with its own validation function.

const STEPS = [
  { id: 'account', label: 'Account', component: AccountStep, validate: validateAccount },
  { id: 'preferences', label: 'Preferences', component: PreferencesStep, validate: () => ({}) },
  { id: 'team', label: 'Team', component: TeamStep, validate: validateTeam },
  { id: 'done', label: 'Done', component: DoneStep, validate: () => ({}) },
];

function OnboardingWizard() {
  const [state, dispatch] = usePersistentStepper('onboarding', initialState);
  const CurrentStepComponent = STEPS[state.currentStep].component;

  return (
    <div className="onboarding-wrapper">
      <StepIndicator
        steps={STEPS}
        currentStep={state.currentStep}
        visited={state.visited}
        onStepClick={(i) => dispatch({ type: 'GO_TO', payload: i })}
      />
      <div className="step-content">
        <CurrentStepComponent
          data={state.formData}
          errors={state.errors[state.currentStep] || {}}
          onChange={(data) => dispatch({ type: 'SET_DATA', payload: data })}
        />
      </div>
      <div className="step-nav">
        {state.currentStep > 0 && (
          <button onClick={() => dispatch({ type: 'PREV' })}>Back</button>
        )}
        {state.currentStep < STEPS.length - 1 && (
          <button
            onClick={() =>
              handleNext(STEPS[state.currentStep].validate, dispatch, state.currentStep)
            }
          >
            Next
          </button>
        )}
      </div>
    </div>
  );
}

Each step component receives data, errors, and onChange — that's it. They don't know about the stepper's internals. You can test them in isolation, reuse them in different wizard contexts, and swap them out without touching the shell.

What's the difference between this and throwing it together with three useState calls? Mainly: it handles edge cases gracefully, it persists, it's accessible, and it doesn't need a full rewrite six months from now when requirements change.

For the visual layer, check out Empire UI's templates for inspiration on onboarding layouts. The structure here adapts to basically any design style, whether you're going minimal or something richer like aurora or cyberpunk aesthetics.

FAQ

Should I use a library or build my own stepper component?

For simple 3-4 step flows, build your own — the overhead of adding a library for something this contained rarely pays off. Libraries make sense when you need complex branching logic, dynamic step insertion, or you're maintaining a design system at scale.

How do I handle conditional steps in a stepper?

Filter your steps array before rendering based on form data. Keep all steps defined but pass a visible flag, then skip invisible steps in navigation logic. Avoid mutating the step array mid-flow — it makes index tracking a nightmare.

What's the best way to test a multi-step form in React?

Test each step component in isolation with unit tests, then write integration tests that simulate the full flow using React Testing Library. Focus on the transition logic and validation gating — those are where bugs actually live.

Can I animate between steps in a React stepper?

Yes — wrap the step content in a Framer Motion AnimatePresence block and set an exit animation on the outgoing step. A simple horizontal slide with x: -20 exit and x: 20 initial entry feels natural and takes about 10 lines.

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

Read next

Onboarding Flow in React: Multi-Step, Spotlight and Tooltip ToursSurvey Form in React: Multi-Step, Progress, Scale and Multiple ChoiceGlassmorphism Onboarding UI: Multi-Step Wizard With Frosted Steps10 Tailwind Component Patterns Every Developer Should Know