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)
| Prop | Type | Default | Description |
|---|---|---|---|
| steps* | Step[] | — | Ordered steps; each has id, title, optional description, content, canProceed gate, and disabledReason for the hover tooltip. |
| current | number | — | 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. |
| allowStepClick | boolean | true | Allow clicking a completed step in the indicator to jump back. |
| backLabel | string | "Back" | Back button label. |
| nextLabel | string | "Next" | Next button label. |
| finishLabel | string | "Finish" | Last-step button label. |
| footerStart | ReactNode | — | Extra content in the footer, left of the nav buttons. |
| className | string | — | Extra classes on the wrapper. |
3-step signup wizard
Account → Plan → Review, with per-step validation gating Next and a Finish state.
Account
Tell us who you are.
Full source
src/components/ui/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
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 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 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
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 };
}