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

StateBadgeLabel colourInteractive
UpcomingNumber, border onlyTertiaryNo
CurrentNumber, accent fillAccentNo
CompletedCheckmark, accent subtleSecondaryYes — 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.