Progress Stepper Component in React: Wizard UI with State
Build a React progress stepper component from scratch — state management, animated transitions, accessible markup, and Tailwind v4 styling for multi-step wizard UIs.
Why Progress Steppers Are Harder Than They Look
Honestly, a progress stepper looks like a five-minute job until you actually sit down and build one properly. Then you've got state synchronization, animated connector lines, accessible ARIA roles, conditional step validation, and a back button that doesn't blow up your form data — and suddenly it's a whole afternoon.
The core problem is that a stepper is really three things at once: a navigation component, a progress indicator, and a state machine. Most tutorial implementations treat it like a simple counter with currentStep + 1. That works until your product manager asks for step skipping, or your designer wants the connector to animate from left to right as the user advances.
This article builds the whole thing right. We'll cover state structure, the rendered markup, Tailwind v4.0.2 styling, animated connectors with CSS transitions, and the accessibility attributes that make screen readers actually usable. No shortcuts.
Designing the State Shape for a Multi-Step Wizard
Before touching JSX, think about what data the stepper actually needs to hold. A bare minimum is currentStep (an index) and an array of step definitions. But real-world wizards also need per-step completion status, optional step metadata like labels and icons, and sometimes error state per step.
Here's a state shape that scales well without overengineering it:
type StepStatus = 'upcoming' | 'active' | 'complete' | 'error';
interface Step {
id: string;
label: string;
description?: string;
status: StepStatus;
}
interface StepperState {
steps: Step[];
currentIndex: number;
}
const initialState: StepperState = {
currentIndex: 0,
steps: [
{ id: 'account', label: 'Account', status: 'active' },
{ id: 'profile', label: 'Profile', status: 'upcoming' },
{ id: 'billing', label: 'Billing', status: 'upcoming' },
{ id: 'confirm', label: 'Confirm', status: 'upcoming' },
],
};Storing status on each step (rather than computing it from currentIndex) gives you flexibility to mark a step as error without resetting the whole flow. You can also skip steps by setting them to complete programmatically. This is particularly useful in onboarding wizards where some steps are optional.
Building the Stepper Component with useReducer
useState works fine for a two-step toggle. Once you've got four or more steps and transitions between them, useReducer earns its keep. The reducer handles NEXT, PREV, GO_TO, and SET_ERROR actions cleanly, and it keeps all mutation logic in one place instead of scattered across onClick handlers.
type Action =
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'GO_TO'; index: number }
| { type: 'SET_ERROR'; index: number };
function stepperReducer(state: StepperState, action: Action): StepperState {
const { steps, currentIndex } = state;
switch (action.type) {
case 'NEXT': {
if (currentIndex >= steps.length - 1) return state;
const updated = steps.map((step, i) => ({
...step,
status:
i < currentIndex + 1 ? 'complete'
: i === currentIndex + 1 ? 'active'
: step.status,
})) as Step[];
return { steps: updated, currentIndex: currentIndex + 1 };
}
case 'PREV': {
if (currentIndex <= 0) return state;
const updated = steps.map((step, i) => ({
...step,
status:
i === currentIndex - 1 ? 'active'
: i === currentIndex ? 'upcoming'
: step.status,
})) as Step[];
return { steps: updated, currentIndex: currentIndex - 1 };
}
case 'SET_ERROR': {
const updated = steps.map((step, i) =>
i === action.index ? { ...step, status: 'error' as StepStatus } : step
);
return { ...state, steps: updated };
}
default:
return state;
}
}The PREV case resets the leaving step back to upcoming rather than complete. That's intentional — if a user goes back to change something, the step they departed is no longer verified. You can change this if your form validates on the way out, but the conservative default prevents false-positive completion indicators.
Rendering the Step Indicators and Connector Lines
The visual layer is a horizontal list of circles connected by lines. Each circle shows a number, a checkmark, or an error icon depending on status. The connector between them fills in as steps complete. Getting that fill animation right is where most implementations fall apart.
The cleanest approach is a two-layer connector: a grey background track, and an absolutely positioned foreground bar whose width transitions from 0% to 100%. You drive the width with an inline style computed from step completion state, and CSS transition: width 300ms ease handles the animation for free.
// StepConnector.tsx
interface ConnectorProps {
filled: boolean;
}
export function StepConnector({ filled }: ConnectorProps) {
return (
<div className="relative flex-1 h-0.5 bg-zinc-700 mx-2">
<div
className="absolute inset-y-0 left-0 bg-indigo-500 transition-all duration-300 ease-in-out"
style={{ width: filled ? '100%' : '0%' }}
/>
</div>
);
}
// Step indicator circle
function StepCircle({ step, index }: { step: Step; index: number }) {
const base = 'w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors duration-200';
const styles: Record<StepStatus, string> = {
upcoming: `${base} bg-zinc-800 text-zinc-400 border border-zinc-600`,
active: `${base} bg-indigo-600 text-white ring-2 ring-indigo-400 ring-offset-2 ring-offset-zinc-900`,
complete: `${base} bg-emerald-600 text-white`,
error: `${base} bg-red-600 text-white`,
};
return (
<div className={styles[step.status]} aria-current={step.status === 'active' ? 'step' : undefined}>
{step.status === 'complete' ? '✓' : step.status === 'error' ? '✕' : index + 1}
</div>
);
}Notice aria-current="step" on the active circle. That single attribute tells screen reader users where they are in the flow without any additional labeling gymnastics. Pair it with an aria-label on the outer <nav> element and you've covered the basic accessibility contract. You can see how similar interactive patterns handle keyboard navigation in our animated tabs component.
Wiring Up the Full Stepper with Step Content Panels
The stepper indicator row is the navigation layer. Below it you need a content panel that renders the current step's form or UI. The simplest approach is an array of React nodes indexed by currentIndex. For larger wizards, lazy-load each panel with React.lazy to keep the initial bundle small.
// Stepper.tsx — the full assembly
export function Stepper() {
const [state, dispatch] = useReducer(stepperReducer, initialState);
const { steps, currentIndex } = state;
const panels = [
<AccountForm key="account" />,
<ProfileForm key="profile" />,
<BillingForm key="billing" />,
<ConfirmPanel key="confirm" />,
];
return (
<div className="w-full max-w-2xl mx-auto p-6 space-y-8">
{/* Step indicator row */}
<nav aria-label="Checkout progress" className="flex items-center">
{steps.map((step, i) => (
<>
<div key={step.id} className="flex flex-col items-center gap-1">
<StepCircle step={step} index={i} />
<span className="text-xs text-zinc-400 hidden sm:block">{step.label}</span>
</div>
{i < steps.length - 1 && (
<StepConnector key={`conn-${i}`} filled={steps[i].status === 'complete'} />
)}
</>
))}
</nav>
{/* Active step content */}
<div role="region" aria-live="polite" className="min-h-48">
{panels[currentIndex]}
</div>
{/* Navigation buttons */}
<div className="flex justify-between pt-4">
<button
onClick={() => dispatch({ type: 'PREV' })}
disabled={currentIndex === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-200 disabled:opacity-40"
>
Back
</button>
<button
onClick={() => dispatch({ type: 'NEXT' })}
disabled={currentIndex === steps.length - 1}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white disabled:opacity-40"
>
{currentIndex === steps.length - 2 ? 'Review' : 'Continue'}
</button>
</div>
</div>
);
}The aria-live="polite" on the content region tells screen readers to announce the new panel after the user navigates, without interrupting anything already being read. That's the right level of intrusiveness for a stepper — you're not alerting the user to an emergency, just updating the view. For button styling that adapts to theme changes at runtime, check out the theme toggle pattern.
Styling with Tailwind v4 and the Empire UI Design System
Empire UI's component tokens in Tailwind v4.0.2 map directly to the stepper's states. The indigo-600 active state, zinc-800 backgrounds, and emerald-600 completion color above aren't arbitrary — they come from the Empire UI palette that works across all 40 visual styles the library ships with.
One thing worth knowing: if you're running Tailwind v4's CSS-first config mode, dynamic class names like bg-${color}-600 won't be detected by the scanner. Write out all your status-variant classes explicitly or use a safelist in your @config block. This catches a lot of people when they first move from v3's JS config.
The 8px gap (Tailwind gap-2) between step circles and their labels is a conscious choice — it's tight enough to keep the indicator compact on mobile but spacious enough that the label doesn't feel like it's fighting the circle for attention. For comparison, the animated button component uses a gap-3 internally for its icon-text pairing, and that extra 4px makes a real visual difference at larger type sizes.
Handling Step Validation and Async Submission
Blocking the Continue button until the current step's form is valid is table stakes. But what do you do when validation is async — an email uniqueness check, a payment method verification? You can't just await inside a reducer dispatch.
The cleanest pattern is to keep async work outside the reducer entirely. The onClick handler on Continue runs your validation promise, and only dispatches NEXT if it resolves. If it rejects, dispatch SET_ERROR with the current step index. The UI reflects the error state immediately, and the user stays on the failing step.
Are you tempted to reach for a form library like React Hook Form here? That's totally reasonable. RHF's trigger() method lets you validate a subset of fields on demand, so you'd call trigger(['email', 'password']) on Continue and only advance if it returns true. The stepper reducer stays pure and the form library handles field-level error messages. They compose well.
Accessibility Checklist Before You Ship
Screen reader users need to know: how many steps are there, which one are they on, and what just changed. You've got aria-current="step" on the active circle and aria-live="polite" on the content region. Add aria-label to each step button if the circles are clickable for non-linear navigation.
Keyboard navigation matters too. If circles are clickable buttons, they need to be focusable and respond to Enter and Space. If you're rendering them as <div> elements, add role="button" and a tabIndex={0} with appropriate key handlers. Better yet, just use <button> — the browser handles all of that for free and you've got less code to maintain.
Color alone can't convey status. The step circles here use different background colors for active, complete, and error states — but they also use different content (a number, a checkmark, or an X). That redundancy means color-blind users still get the full picture. This is the same principle that makes cards-stack components work across all color modes without extra effort.
FAQ
Yes, and it's a natural fit. Keep the stepper state in useReducer at the page level, wrap each step panel in its own RHF FormProvider or pass the form instance down via context. Call RHF's trigger() method on Continue to validate only the current step's fields before dispatching NEXT. This keeps the stepper logic decoupled from form logic.
Wrap the panel container in a Framer Motion AnimatePresence and key each panel by currentIndex. Set initial={{ opacity: 0, x: 20 }}, animate={{ opacity: 1, x: 0 }}, and exit={{ opacity: 0, x: -20 }} with a duration around 0.2s. Going back? Flip the x values. You can also use CSS only with a clip-path or transform transition if you want to avoid the Framer Motion dependency.
Serialize the StepperState to sessionStorage on every dispatch using a useEffect. On mount, read it back and initialize useReducer with the saved value instead of initialState. Use sessionStorage rather than localStorage for wizard flows — the data should clear when the user closes the tab, not persist indefinitely.
In Tailwind v4.0.2's CSS-first config mode, the content scanner can't detect class names constructed at runtime (e.g., template literals like bg-${color}-600). Write all status-variant classes as full static strings — bg-indigo-600, bg-emerald-600, bg-red-600 — or add them to the safelist in your @config block. This is a very common gotcha when migrating from v3.
Buttons if they're clickable for non-linear navigation; divs or spans if they're purely decorative indicators. Never add click handlers to a div without also adding role='button', tabIndex={0}, and keyboard event handlers. The semantic <button> element gives you all of that plus :focus-visible styling for free, so it's always the easier choice.
Add a local isValidating boolean to the component (useState, not in the reducer). Set it to true before the async call and back to false in the finally block. Disable the button and show a spinner while isValidating is true. The reducer stays synchronous and pure; the component handles the async UI concern. Keep those responsibilities separate and the code stays readable.