Stepper Progress Form: Linear Flow with Validation
Build a multi-step form with a stepper progress indicator in React and Tailwind. Covers per-step validation, state management, and accessible UX patterns.
Why Stepper Forms Beat Single-Page Forms
Honestly, dumping a 12-field form onto one page is one of the worst things you can do to a user. People see a wall of inputs and their brain calculates the exit. Stepper forms slice that cognitive load into digestible chunks.
The basic idea: you split the form into numbered steps, show which step the user is on, and only advance when the current step passes validation. Simple concept. Surprisingly annoying to implement well.
Empire UI's stepper component handles the progress bar, step labels, and connector lines out of the box. You wire up the validation logic and the step content — that's it. No fighting with CSS for an afternoon to get the connector lines to render correctly between circles.
If you've spent time building things like animated tab interfaces in React, you'll notice the stepper shares similar state-machine thinking — the active index drives everything.
Stepper Component Anatomy
Before writing code, it helps to understand the three visual layers. First, the step indicators: circles or squares that show step number, completion checkmark, or error state. Second, the connector lines between those indicators. Third, the step labels sitting below or beside each indicator.
The tricky part is the connector line. It needs to fill from left to right as steps complete. Most naive implementations just toggle a CSS class, but then you lose the animated fill effect. Empire UI uses a scaleX transform on an absolutely positioned pseudo-element — starts at 0, transitions to 1 when the preceding step is marked complete.
State shape is minimal. You need currentStep (number), completedSteps (Set or boolean array), and stepErrors (object keyed by step index). That's genuinely all the stepper itself needs to know about. The actual form field values live in a separate formData state object.
Don't conflate stepper state with form state. They should be siblings, not nested. This lets you swap out the stepper UI without touching validation logic.
Building the Stepper Component in React and Tailwind
Here's the core stepper renderer. This uses Tailwind v4.0.2 utility classes and assumes you're passing steps, currentStep, and completedSteps as props.
type Step = { label: string; description?: string };
interface StepperProps {
steps: Step[];
currentStep: number;
completedSteps: Set<number>;
onStepClick?: (index: number) => void;
}
export function Stepper({ steps, currentStep, completedSteps, onStepClick }: StepperProps) {
return (
<nav aria-label="Form progress" className="w-full">
<ol className="flex items-start gap-0">
{steps.map((step, index) => {
const isComplete = completedSteps.has(index);
const isActive = index === currentStep;
const isPast = index < currentStep;
return (
<li key={index} className="flex-1 flex flex-col items-center relative">
{/* Connector line */}
{index < steps.length - 1 && (
<div className="absolute top-4 left-1/2 w-full h-0.5 bg-neutral-200">
<div
className="h-full bg-indigo-600 transition-transform duration-400 origin-left"
style={{ transform: isComplete || isPast ? 'scaleX(1)' : 'scaleX(0)' }}
/>
</div>
)}
{/* Step circle */}
<button
onClick={() => isComplete && onStepClick?.(index)}
disabled={!isComplete && !isActive}
aria-current={isActive ? 'step' : undefined}
className={[
'relative z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors duration-200',
isComplete
? 'border-indigo-600 bg-indigo-600 text-white cursor-pointer'
: isActive
? 'border-indigo-600 bg-white text-indigo-600'
: 'border-neutral-300 bg-white text-neutral-400 cursor-default',
].join(' ')}
>
{isComplete ? (
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.5 11.5L3 8l1.4-1.4 2.1 2.1 4.6-4.6L12.5 5.5z" />
</svg>
) : (
index + 1
)}
</button>
{/* Label */}
<span
className={[
'mt-2 text-xs font-medium text-center max-w-[80px]',
isActive ? 'text-indigo-600' : isComplete ? 'text-neutral-700' : 'text-neutral-400',
].join(' ')}
>
{step.label}
</span>
</li>
);
})}
</ol>
</nav>
);
}Notice the connector line uses origin-left with scaleX — that's the cleanest way to do a fill animation without JS-calculated widths. The transition takes 400ms which feels snappy without being jarring.
The onStepClick callback only fires for completed steps. This lets users go back and edit earlier steps without being able to jump forward arbitrarily. That's an intentional UX constraint.
Per-Step Validation Logic
Here's the thing: validation that runs on every keystroke is exhausting. Per-step validation runs once when the user clicks 'Next'. If it fails, you show errors inline and refuse to advance. Clean.
type StepValidator = (data: Partial<FormData>) => Record<string, string>;
const stepValidators: StepValidator[] = [
// Step 0 — Personal info
(data) => {
const errors: Record<string, string> = {};
if (!data.firstName?.trim()) errors.firstName = 'First name is required';
if (!data.email?.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
errors.email = 'Enter a valid email address';
}
return errors;
},
// Step 1 — Account setup
(data) => {
const errors: Record<string, string> = {};
if (!data.password || data.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (data.password !== data.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
return errors;
},
// Step 2 — Review (no validation needed)
() => ({}),
];
function handleNext(currentStep: number, formData: FormData, setErrors: ..., setCurrentStep: ...) {
const validator = stepValidators[currentStep];
const errors = validator(formData);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return; // Don't advance
}
setErrors({});
setCompletedSteps(prev => new Set(prev).add(currentStep));
setCurrentStep(prev => prev + 1);
}The validator array approach scales nicely. Add a step, add a validator. Each validator receives the full formData object so cross-field validation (like password confirmation) is straightforward. You don't need a form library for this — it's just functions.
If you're adding async validation — checking if an email already exists, for instance — return a Promise from the validator and await it in handleNext. Just make sure to show a loading state on the Next button while it's pending.
Accessible Stepper: aria-current, role, and Focus Management
Accessibility on steppers gets ignored constantly. Here are the three things that actually matter.
First, the nav element wrapping the stepper should have aria-label="Form progress". The step list should be an <ol> since the steps have meaningful order. Each active step button gets aria-current="step". That's the correct ARIA attribute — not aria-selected, not aria-checked.
Second, when you advance to a new step, move focus to the step's heading or the first input in the new panel. Without this, keyboard users will have no idea the page changed. A useEffect that calls ref.current?.focus() after currentStep changes handles it. Set tabIndex={-1} on the target element so it's focusable without being in the tab order normally.
Third, don't disable the Back button. Users need to be able to go back freely. Only the forward navigation should gate on validation. This is especially important if you want your form to work without JavaScript — though that's a much bigger conversation.
Connecting the Stepper to Empire UI's Theme System
Empire UI ships 40 visual styles, and steppers are one of those components that should inherit whatever style is active. The default stepper uses indigo-600 for the active/complete color, but if your project uses a glassmorphism theme (check out what glassmorphism actually is if you haven't already), you'll want a different treatment.
In glassmorphism mode, the step circles get background: rgba(255,255,255,0.15) with a backdrop-filter: blur(12px) applied, and the connector line uses a gradient from rgba(255,255,255,0.3) to rgba(255,255,255,0.08). It reads clearly against dark or image backgrounds.
Empire UI's CSS custom properties make this swap clean. Override --stepper-active-bg, --stepper-complete-bg, and --stepper-connector-color in your theme's CSS layer. No component code changes required. The same system works for theme toggling between light and dark mode — the stepper respects the active color scheme automatically.
If you want the 40-style system to feel coherent, make sure your stepper indicators match your button border-radius. 8px gap between the label and indicator circle. Consistent sizing — 32px circles for standard forms, 40px if you're building something that needs to feel premium.
Animating Step Transitions Without Overcomplicating It
Step content transitions are where developers lose hours. You think you need AnimatePresence, exit animations, slide directions based on whether you're going forward or backward. Maybe you do. But start simpler.
A fade-in works for 80% of cases. Use a CSS animation triggered by remounting the step content, or assign a key prop equal to currentStep on the container. React will unmount and remount on key change, which automatically triggers CSS @keyframes fadeIn if you attach it to the container class.
@keyframes stepFadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-panel {
animation: stepFadeIn 200ms ease-out both;
}200ms is fast enough to feel responsive without feeling abrupt. translateY(6px) — not 20px — gives just a hint of upward motion without being distracting. If you want directional slides (left when going forward, right when going back), track a direction ref and conditionally apply slideInLeft or slideInRight keyframes. That's maybe 20 extra lines and a useRef. Worth it for checkout flows, overkill for settings wizards.
Full Example: Three-Step Registration Form
Putting it all together: a three-step registration form with personal info, account setup, and a confirmation review. The complete component is in the Empire UI repository under components/forms/StepperForm. Here's the outer shell showing how state flows together.
export function RegistrationWizard() {
const steps = [
{ label: 'Your Info' },
{ label: 'Account' },
{ label: 'Review' },
];
const [currentStep, setCurrentStep] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [formData, setFormData] = useState<Partial<FormData>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
panelRef.current?.focus();
}, [currentStep]);
const advance = () => handleNext(currentStep, formData, setErrors, setCurrentStep, setCompletedSteps);
const retreat = () => setCurrentStep(prev => Math.max(0, prev - 1));
return (
<div className="max-w-lg mx-auto px-4 py-8 space-y-8">
<Stepper
steps={steps}
currentStep={currentStep}
completedSteps={completedSteps}
onStepClick={setCurrentStep}
/>
<div
key={currentStep}
ref={panelRef}
tabIndex={-1}
className="step-panel outline-none"
>
{currentStep === 0 && (
<PersonalInfoStep data={formData} errors={errors} onChange={setFormData} />
)}
{currentStep === 1 && (
<AccountStep data={formData} errors={errors} onChange={setFormData} />
)}
{currentStep === 2 && <ReviewStep data={formData} />}
</div>
<div className="flex justify-between pt-4">
<button
onClick={retreat}
disabled={currentStep === 0}
className="px-4 py-2 text-sm font-medium text-neutral-600 disabled:opacity-40"
>
Back
</button>
{currentStep < steps.length - 1 ? (
<button
onClick={advance}
className="px-6 py-2 text-sm font-semibold bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors"
>
Next
</button>
) : (
<button
onClick={handleSubmit}
className="px-6 py-2 text-sm font-semibold bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Submit
</button>
)}
</div>
</div>
);
}The key={currentStep} on the panel div is doing the animation work silently. Every step change remounts the panel and triggers the CSS animation. Focus management is the useEffect calling panelRef.current?.focus(). Two lines of code for solid keyboard accessibility.
For larger apps, this same pattern works as a checkout wizard. Pair it with the animated button component on the Next/Submit actions for micro-interaction polish. The stepper is genuinely one of those components that, once you have a solid version, you'll copy into every project.
FAQ
Yes. The stepper component manages step progression state independently. You handle form field values and validation with plain useState and your own validator functions. No library needed unless your validation rules are complex enough to justify the overhead.
Make your step validator return a Promise. In your handleNext function, await the result before checking for errors. Set a loading boolean on the Next button during the async check so users don't double-click. Something like: const errors = await stepValidatorscurrentStep; then check Object.keys(errors).length.
Wrap the stepper in a <nav aria-label="Form progress">. Use an <ol> for the step list. Add aria-current="step" to the active step button. For completed steps that are clickable (to go back), ensure they have a descriptive aria-label like "Step 1: Your Info, completed". Avoid aria-selected here — that's for tabs and listboxes.
Serialize completedSteps and formData to sessionStorage inside a useEffect that runs on every state change. On mount, read from sessionStorage and hydrate state. Use a unique key per flow (e.g., 'onboarding-wizard-v2') so schema changes between deploys don't restore stale data.
The connector line uses absolute positioning relative to the step <li> element, which requires the <ol> to use flex layout. On mobile with narrow screens, step labels can overflow and push the circles off-center. Add a min-w-0 class to each <li> and use text-ellipsis or hide labels below a breakpoint with hidden sm:block on the label span.
Yes. Change the <ol> from flex-row to flex-col. The connector line becomes a vertical bar — set it to position: absolute, left: 50% (or left: 16px if aligning to the circle edge), height: 100%, width: 2px. The scaleY animation replaces scaleX. Step labels sit to the right of each circle with ml-4.