MultiStepForm

An accessible wizard that splits a long form into ordered steps with a progress indicator, per-step validation gating, and Back/Next/Finish.

import { MultiStepForm }from "@/components/ui"

Specification

Breaks a long form into ordered steps to respect working-memory limits (Miller's Law) and reduce per-screen choices (Hick's Law), with a visible progress indicator to set expectations. Advancing can be gated per step via canProceed. Controlled (current + onStepChange) or self-managed; mobile-responsive (the indicator collapses to a compact progress bar).

Anatomy

  • Step indicator: numbered circles + titles with connectors (desktop); compact 'Step x of n' + progress bar (mobile)
  • Current step: title, optional description, and the step content
  • Footer: Back + Next/Finish (with disabledReason tooltip when gated), with an optional footerStart slot

States

  • Per step: upcoming / current / completed (check)
  • Next/Finish disabled when the current step's canProceed is false; hover shows disabledReason tooltip
  • Completed steps are clickable to jump back (when allowStepClick)

Accessibility

  • Indicator is an ordered list; the current step marks aria-current=step
  • Step navigation is via real <button>s with focus rings
  • Progress is conveyed by text ('Step x of n') as well as the bar, not color alone

Spec sourced from mathesis ui-component/multi-step-form

Props (10)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
steps*Step[]—Ordered steps; each has id, title, optional description, content, canProceed gate, and disabledReason for the hover tooltip.
currentnumber—Controlled active step index. Omit to let the component manage it.
onStepChange(index: number) => void—Notified when the active step changes.
onComplete() => void—Called when Finish is pressed on the last step.
allowStepClickbooleantrueAllow clicking a completed step in the indicator to jump back.
backLabelstring"Back"Back button label.
nextLabelstring"Next"Next button label.
finishLabelstring"Finish"Last-step button label.
footerStartReactNode—Extra content in the footer, left of the nav buttons.
classNamestring—Extra classes on the wrapper.

3-step signup wizard

Account → Plan → Review, with per-step validation gating Next and a Finish state.

Step 1 of 3Account

Account

Tell us who you are.

 

 

Full source
The complete component, read from src/components/ui/multi-step-form.tsx.
multi-step-form.tsx
"use client";

import { useEffect, useState, type ReactNode } from "react";
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";

export interface Step {
  /** Stable id. */
  id: string;
  /** Short step title shown in the indicator. */
  title: string;
  /** Optional one-line description under the title (current step only). */
  description?: string;
  /** The step's content (form fields, etc.). */
  content: ReactNode;
  /**
   * Gate for advancing past this step. Return false to disable Next/Finish
   * (e.g. when the step's fields are invalid). Defaults to allowed.
   */
  canProceed?: boolean;
  /**
   * Whether this step is complete/valid. Drives the green check in the
   * indicator so it reflects actual completion rather than mere position —
   * important when returning to the wizard on a later step, where earlier
   * steps should show green only if their data is still valid. When omitted,
   * falls back to `canProceed`, then to positional completion (before current).
   */
  completed?: boolean;
  /**
   * Reason why canProceed is false. Shown as a tooltip when hovering/focusing
   * the disabled Next/Finish button. Supports multi-line via "\n".
   * Leave undefined when the step is valid.
   */
  disabledReason?: string;
}

interface MultiStepFormProps {
  steps: Step[];
  /** Controlled active step index. Omit to let the component manage it. */
  current?: number;
  /** Notified when the active step changes (both controlled + uncontrolled). */
  onStepChange?: (index: number) => void;
  /** Called when the last step's Finish is pressed. */
  onComplete?: () => void;
  /** Allow clicking a completed step in the indicator to jump back. Default true. */
  allowStepClick?: boolean;
  /** Labels for the navigation buttons. */
  backLabel?: string;
  nextLabel?: string;
  finishLabel?: string;
  /** Extra content rendered in the footer, left of the nav buttons. */
  footerStart?: ReactNode;
  className?: string;
}

/**
 * MultiStepForm — an accessible wizard that breaks a long form into ordered
 * steps with a progress indicator and Back/Next/Finish navigation. Advancing
 * can be gated per step via `canProceed`. Can be controlled (`current` +
 * `onStepChange`) or self-managed. Mobile-responsive: the step indicator
 * collapses to a compact "Step x of n" + progress bar on small screens.
 *
 * Grounded in the Mathesis UX books: chunking a long task into steps respects
 * working-memory limits (Miller's Law) and reduces per-screen choices
 * (Hick's Law); a visible progress indicator sets expectations (Doherty).
 */
export function MultiStepForm({
  steps,
  current,
  onStepChange,
  onComplete,
  allowStepClick = true,
  backLabel = "Back",
  nextLabel = "Next",
  finishLabel = "Finish",
  footerStart,
  className,
}: MultiStepFormProps) {
  const [internal, setInternal] = useState(0);
  // Track the furthest step the user has reached (for allowing forward navigation)
  const [furthest, setFurthest] = useState(0);
  const active = current ?? internal;
  const clamped = Math.max(0, Math.min(active, steps.length - 1));
  const step = steps[clamped];
  const isFirst = clamped === 0;
  const isLast = clamped === steps.length - 1;
  const canProceed = step?.canProceed ?? true;
  const disabledReason = !canProceed ? step?.disabledReason : undefined;

  // Track the furthest reached step in an effect (never during render).
  useEffect(() => {
    setFurthest((f) => (clamped > f ? clamped : f));
  }, [clamped]);

  /**
   * Check if we can navigate to a given step.
   * - Can always go back to any visited step
   * - Can go forward only if all steps before target are valid
   */
  const canNavigateTo = (targetIndex: number): boolean => {
    if (!allowStepClick) return false;
    if (targetIndex === clamped) return false; // Already here
    
    // Going backward: always allowed to visited steps
    if (targetIndex < clamped) return true;
    
    // Going forward: only if we've been there before AND all intermediate steps are valid
    if (targetIndex > furthest) return false;
    
    // Check all steps from current to target-1 are valid
    for (let i = clamped; i < targetIndex; i++) {
      if (!(steps[i]?.canProceed ?? true)) return false;
    }
    return true;
  };

  const go = (index: number) => {
    const next = Math.max(0, Math.min(index, steps.length - 1));
    if (current === undefined) setInternal(next);
    onStepChange?.(next);
  };

  const onNext = () => {
    if (!canProceed) return;
    if (isLast) onComplete?.();
    else go(clamped + 1);
  };

  if (!step) return null;

  return (
    <div className={cn("w-full", className)}>
      {/* Step indicator — full on ≥sm, compact on mobile. */}
      <ol className="mb-6 hidden items-center sm:flex" aria-label="Progress">
        {steps.map((s, i) => {
          // Green/check reflects completion validity, not mere position: prefer
          // an explicit `completed`, then `canProceed`, then positional (passed).
          const done = s.completed ?? s.canProceed ?? i < clamped;
          const visited = i <= furthest;
          const isCurrent = i === clamped;
          const clickable = canNavigateTo(i);
          return (
            <li key={s.id} className={cn("flex items-center", i < steps.length - 1 && "flex-1")}>
              <button
                type="button"
                disabled={!clickable}
                aria-current={isCurrent ? "step" : undefined}
                onClick={() => clickable && go(i)}
                className={cn(
                  "flex items-center gap-2 rounded-md p-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
                  clickable ? "cursor-pointer hover:bg-bg-hover" : "cursor-default"
                )}
              >
                <span
                  className={cn(
                    "flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold transition-colors",
                    done && !isCurrent && "border-accent bg-accent text-on-accent",
                    isCurrent && "border-accent text-accent",
                    !done && !isCurrent && visited && "border-accent-muted text-accent",
                    !done && !isCurrent && !visited && "border-border text-text-tertiary"
                  )}
                >
                  {done && !isCurrent ? <Check size={14} aria-hidden /> : i + 1}
                </span>
                <span
                  className={cn(
                    "text-sm font-medium",
                    isCurrent ? "text-text" : done ? "text-text-secondary" : visited ? "text-text-secondary" : "text-text-tertiary"
                  )}
                >
                  {s.title}
                </span>
              </button>
              {i < steps.length - 1 && (
                <span className={cn("mx-2 h-px flex-1 transition-colors", done ? "bg-accent" : "bg-border")} aria-hidden />
              )}
            </li>
          );
        })}
      </ol>

      {/* Compact mobile indicator */}
      <div className="mb-5 sm:hidden">
        <div className="mb-1.5 flex items-center justify-between text-xs">
          <span className="font-medium text-text">
            Step {clamped + 1} of {steps.length}
          </span>
          <span className="text-text-secondary">{step.title}</span>
        </div>
        <div className="h-1.5 w-full overflow-hidden rounded-full bg-bg-active">
          <div
            className="h-full rounded-full bg-accent transition-[width] duration-300"
            style={{ width: `${((clamped + 1) / steps.length) * 100}%` }}
          />
        </div>
      </div>

      {/* Current step */}
      <div>
        <h3 className="text-base font-semibold text-text">{step.title}</h3>
        {step.description && <p className="mt-0.5 text-sm text-text-secondary">{step.description}</p>}
        <div className="mt-4">{step.content}</div>
      </div>

      {/* Navigation */}
      <div className="mt-6 flex items-center gap-2 border-t border-border pt-4">
        {footerStart}
        <div className="ml-auto flex items-center gap-2">
          {!isFirst && (
            <Button variant="secondary" icon={ChevronLeft} onClick={() => go(clamped - 1)}>
              {backLabel}
            </Button>
          )}
          <Button
            iconRight={isLast ? undefined : ChevronRight}
            icon={isLast ? Check : undefined}
            disabled={!canProceed}
            disabledReason={disabledReason}
            onClick={onNext}
          >
            {isLast ? finishLabel : nextLabel}
          </Button>
        </div>
      </div>
    </div>
  );
}

Dependencies

To use MultiStepForm you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ button.tsx
  └─ multi-step-form.tsx
src/hooks/
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/hooks/use-mounted.ts
use-mounted.ts
"use client";

import { useState, useEffect } from "react";

/**
 * Returns true once the component has mounted on the client.
 * Useful for portals and anything that needs `document` to exist.
 */
export function useMounted(): boolean {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}
src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";

import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";

interface PopoverPositionOptions {
  align?: "start" | "center" | "end";
  gap?: number;
  viewportPadding?: number;
  preferredHeight?: number;
  matchWidth?: boolean;
  minWidth?: number;
}

interface PopoverPosition {
  placement: "top" | "bottom";
  style: CSSProperties;
}

/**
 * Compute a fixed-position popover style anchored to a ref element.
 * Opens downward by default; flips upward when there isn't room below
 * AND there's more room above. Clamps horizontally so nothing renders
 * past the viewport edge. Re-measures on scroll/resize while open.
 */
export function usePopoverPosition(
  anchorRef: RefObject<HTMLElement | null>,
  open: boolean,
  options: PopoverPositionOptions = {}
): PopoverPosition | null {
  const {
    align = "start",
    gap = 4,
    viewportPadding = 8,
    preferredHeight = 320,
    matchWidth = false,
    minWidth,
  } = options;

  const [position, setPosition] = useState<PopoverPosition | null>(null);

  const measure = useCallback(() => {
    const anchor = anchorRef.current;
    if (!anchor) return;
    const rect = anchor.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const spaceBelow = vh - rect.bottom - viewportPadding;
    const spaceAbove = rect.top - viewportPadding;
    const placement =
      spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
        ? "top"
        : "bottom";
    const maxHeight = Math.max(
      120,
      Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
    );
    const width = matchWidth ? rect.width : minWidth;
    let left = align === "end" && width ? rect.right - width : rect.left;
    if (align === "center" && width)
      left = rect.left + rect.width / 2 - width / 2;
    left = width
      ? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
      : Math.min(Math.max(left, viewportPadding), vw - viewportPadding);

    setPosition({
      placement,
      style: {
        position: "fixed",
        top: placement === "top" ? undefined : rect.bottom + gap,
        bottom: placement === "top" ? vh - rect.top + gap : undefined,
        left,
        width: matchWidth ? rect.width : undefined,
        minWidth: !matchWidth ? minWidth : undefined,
        maxHeight,
      },
    });
  }, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);

  useEffect(() => {
    if (!open) {
      setPosition(null);
      return;
    }
    measure();
    window.addEventListener("scroll", measure, true);
    window.addEventListener("resize", measure);
    return () => {
      window.removeEventListener("scroll", measure, true);
      window.removeEventListener("resize", measure);
    };
  }, [open, measure]);

  return position;
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}