New
Multi-step Wizard
A linear flow that breaks a complex task into discrete, completable steps. Each step is validated before advancing. Users can navigate back to completed steps.
Preview
When to use
- →Onboarding flows where each step's data depends on the previous (account → profile → preferences).
- →Complex forms with 5+ fields where grouping reduces cognitive load.
- →Checkout flows: cart → shipping → payment → confirmation.
- →Not for simple forms with 3 or fewer fields — use a single-page form instead.
Step indicator states
| State | Badge | Label colour | Interactive |
|---|---|---|---|
| Upcoming | Number, border only | Tertiary | No |
| Current | Number, accent fill | Accent | No |
| Completed | Checkmark, accent subtle | Secondary | Yes — click to revisit |
Implementation
Wizard.tsx
tsx
"use client";
import { useState } from "react";
interface Step {
id: string;
title: string;
content: React.ReactNode;
validate?: () => boolean;
}
interface WizardProps {
steps: Step[];
onComplete: (data: Record<string, unknown>) => void;
}
export function Wizard({ steps, onComplete }: WizardProps) {
const [currentStep, setCurrentStep] = useState(0);
const [completed, setCompleted] = useState<Set<number>>(new Set());
const isFirst = currentStep === 0;
const isLast = currentStep === steps.length - 1;
const step = steps[currentStep];
const goNext = () => {
if (step.validate && !step.validate()) return;
setCompleted((s) => new Set([...s, currentStep]));
if (isLast) {
onComplete({});
} else {
setCurrentStep((n) => n + 1);
}
};
const goBack = () => setCurrentStep((n) => Math.max(0, n - 1));
const goTo = (index: number) => {
if (completed.has(index) || index < currentStep) {
setCurrentStep(index);
}
};
return (
<div className="flex flex-col gap-8">
{/* Step indicators */}
<nav aria-label="Progress">
<ol className="flex items-center gap-2">
{steps.map((s, i) => (
<li key={s.id} className="flex items-center gap-2">
<button
onClick={() => goTo(i)}
aria-current={i === currentStep ? "step" : undefined}
disabled={!completed.has(i) && i > currentStep}
className={cn(
"flex items-center gap-2 text-[13px] font-medium transition-colors",
i === currentStep
? "text-[rgb(var(--accent))]"
: completed.has(i)
? "text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"
: "text-[rgb(var(--text-tertiary))] cursor-not-allowed"
)}
>
<span className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold border transition-colors",
i === currentStep
? "bg-[rgb(var(--accent))] border-[rgb(var(--accent))] text-white"
: completed.has(i)
? "bg-[rgb(var(--accent-subtle))] border-[rgb(var(--accent))] text-[rgb(var(--accent))]"
: "border-[rgb(var(--border))] text-[rgb(var(--text-tertiary))]"
)}>
{completed.has(i) && i !== currentStep ? (
<Check className="w-3 h-3" strokeWidth={3} />
) : (
i + 1
)}
</span>
{s.title}
</button>
{i < steps.length - 1 && (
<ChevronRight className="w-3.5 h-3.5 text-[rgb(var(--text-tertiary))]" />
)}
</li>
))}
</ol>
</nav>
{/* Step content */}
<div>
{step.content}
</div>
{/* Navigation */}
<div className="flex justify-between">
<Button variant="secondary" onClick={goBack} disabled={isFirst}>
Back
</Button>
<Button onClick={goNext}>
{isLast ? "Complete" : "Continue"}
</Button>
</div>
</div>
);
}Accessibility
- →The step indicator is a <nav> with aria-label="Progress" — it appears as a landmark.
- →The current step button uses aria-current="step" to tell screen readers which step is active.
- →Completed step buttons are enabled; upcoming ones are disabled — this prevents users from skipping steps.
- →When moving between steps, focus should be managed to the new step's first input or heading so keyboard users are oriented.
- →Each step's content region should have a heading (h2 or h3) identifying the step title for screen reader users.
- →Validation errors must be announced via aria-live or by moving focus to the first invalid field.