Flows
Wizard
A multi-step form that shows one step at a time and checks each step before moving on.
- Built on
<form><fieldset>Constraint validationContainer queries- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.7 kB
- Built from
- Button
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Wizard, WizardStep } from "@/components/equip/wizard";
export default function Demo() {
const [created, setCreated] = useState("");
if (created) {
return (
<div style={{ display: "grid", gap: 8, justifyItems: "center", fontSize: "var(--eq-text-s)" }}>
<p style={{ margin: 0 }}>{created} is ready.</p>
<Button variant="outline" size="s" onClick={() => setCreated("")}>
Start over
</Button>
</div>
);
}
return (
<Wizard
label="Create workspace"
finishLabel="Create"
style={{ width: 224 }}
onComplete={(data) => setCreated(String(data.get("workspace")))}
>
<WizardStep title="Workspace name">
<input name="workspace" aria-label="Workspace name" placeholder="larder" required minLength={3} />
</WizardStep>
<WizardStep title="Region">
<select name="region" aria-label="Region" defaultValue="eu-west-1">
<option value="eu-west-1">eu-west-1, Ireland</option>
<option value="us-east-1">us-east-1, Virginia</option>
<option value="ap-south-1">ap-south-1, Mumbai</option>
</select>
</WizardStep>
<WizardStep title="Confirm">
<p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
You become its owner.
</p>
</WizardStep>
</Wizard>
);
}Examples
Optional step
An optional step adds Skip, which moves on without checking that step's fields.
Code
"use client";
import { useState } from "react";
import { Wizard, WizardStep } from "@/components/equip/wizard";
export default function Example() {
const [result, setResult] = useState("");
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 440 }}>
<Wizard
label="New project"
finishLabel="Create project"
onComplete={(data) => setResult(`Created ${data.get("project")}. Alerts go to ${data.get("alerts")}.`)}
>
<WizardStep title="Project">
<label>
Project name
<input name="project" placeholder="atlas-web" required pattern="[a-z0-9-]+" />
</label>
</WizardStep>
<WizardStep title="Teammates" optional>
<label>
Email addresses
<textarea name="invites" rows={3} placeholder="ana@example.com, li@example.com" />
</label>
</WizardStep>
<WizardStep title="Alerts">
<label>
Send deploy alerts to
<select name="alerts" defaultValue="email">
<option value="email">Email</option>
<option value="slack">Slack channel</option>
<option value="none">Nowhere</option>
</select>
</label>
</WizardStep>
</Wizard>
{result && <p style={{ margin: 0, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>{result}</p>}
</div>
);
}Summary step
The last step reads the answers back; the step list lets people jump to any earlier step to fix one.
Code
"use client";
import { useState } from "react";
import { Wizard, WizardStep } from "@/components/equip/wizard";
export default function Example() {
const [seats, setSeats] = useState("5");
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 440 }}>
<Wizard label="Upgrade to Team" finishLabel="Start trial" onComplete={() => setSent(true)}>
<WizardStep title="Seats">
<label>
Number of seats
<input
name="seats"
type="number"
min={2}
max={200}
required
value={seats}
onChange={(event) => setSeats(event.target.value)}
/>
</label>
</WizardStep>
<WizardStep title="Billing">
<label>
Invoice email
<input
name="email"
type="email"
placeholder="finance@example.com"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
</WizardStep>
<WizardStep title="Review">
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "4px 16px", margin: 0 }}>
<dt style={{ color: "var(--eq-ink-2)" }}>Seats</dt>
<dd style={{ margin: 0 }}>{seats}</dd>
<dt style={{ color: "var(--eq-ink-2)" }}>Invoices to</dt>
<dd style={{ margin: 0 }}>{email}</dd>
<dt style={{ color: "var(--eq-ink-2)" }}>Price</dt>
<dd style={{ margin: 0 }}>${Number(seats) * 12} a month after 14 days</dd>
</dl>
</WizardStep>
</Wizard>
{sent && <p style={{ margin: 0, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>Trial started. The first invoice goes to {email}.</p>}
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Accessible name of the form and its step list. |
onComplete | (data: FormData) => void | none | Called on Finish with the fields of every step. Without it the form submits natively. |
finishLabel | string | "Finish" | Label of the last step's button. |
defaultStep | number | 0 | Zero-based step shown first. Steps before it count as done. |
...rest | ComponentProps<"form"> | none | Every native form attribute, including action, onSubmit and ref. |
WizardStep title | string | none | Shown in the step list and as the step's legend. |
WizardStep optional | boolean | false | Adds a Skip button that moves on without checking the step. |
WizardStep ...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute. Children are the step's fields. |
Anatomy
| root | .eq-wizard | The one form; a size container. |
|---|---|---|
| steps | .eq-wizard-steps | Ordered list; the current item has aria-current="step". |
| item | .eq-wizard-item | data-state is done, current, reached or upcoming. |
| jump | .eq-wizard-jump | A button for steps you can return to, a span otherwise. |
| marker | .eq-wizard-marker | Step number, or a check when done. |
| name | .eq-wizard-name | |
| body | .eq-wizard-body | |
| step | .eq-wizard-step | A fieldset; inactive ones are hidden. |
| legend | .eq-wizard-legend | |
| optional | .eq-wizard-optional | Badge on an optional step's legend. |
| actions | .eq-wizard-actions | |
| back | .eq-wizard-back | |
| visually hidden | .eq-wizard-sr | The live region and the done suffix in the step list. |
Keyboard
| Enter | In a field, goes to the next step after checking this one; on the last step, finishes. |
|---|---|
| TabShift+Tab | Moves through the step list, the current step's fields and the actions. |
| EnterSpace | On a step in the list, jumps to it if every step before it is valid. |
Notes
- Next checks only the visible step's fields with checkValidity and reports the first problem; hidden steps are never validated.
- Each change is announced in a polite live region as Step 2 of 3: Region, and focus moves to the step's first field or its legend.
- Steps you have reached stay in the step list as buttons; later ones cannot be reached until the steps before them are valid.
- Every step's fields stay in the one form, so FormData on finish holds all of them, including fields in a skipped step.
- Below 360px wide, only the current step keeps its name in the list and the legend is visually hidden, which suits narrow panels.
- Unclassed inputs, selects and textareas in a step get the shared text-entry look; give a step any other field part instead.
- The compact layout needs container queries, in every current engine; older engines keep the full step list.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/wizard.tsx234 lines
"use client";
import {
Children,
createContext,
isValidElement,
useContext,
useEffect,
useRef,
useState,
type ComponentProps,
type SubmitEvent,
type ReactElement,
} from "react";
import { Button } from "./button";
import "./wizard.css";
export type WizardStepProps = ComponentProps<"fieldset"> & {
/** Shown in the step list and as the step's legend. */
title: string;
/** Adds a Skip button that moves on without checking the step's fields. */
optional?: boolean;
};
const StepContext = createContext<{ active: boolean } | null>(null);
export type WizardProps = Omit<ComponentProps<"form">, "noValidate"> & {
/** Accessible name of the form and its step list. */
label: string;
/** Called on Finish with every field from every step. Without it the form submits natively. */
onComplete?: (data: FormData) => void;
finishLabel?: string;
/** Zero-based index of the step shown first. Steps before it count as done. */
defaultStep?: number;
};
const FOCUSABLE =
'input:not([type="hidden"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])';
type Control = Element & { checkValidity(): boolean; reportValidity(): boolean };
function controlsOf(fieldset: HTMLFieldSetElement | undefined): Control[] {
if (!fieldset) return [];
return Array.from(fieldset.elements).filter((el): el is Control => "checkValidity" in el);
}
export function Wizard({
label,
onComplete,
finishLabel = "Finish",
defaultStep = 0,
className,
children,
onSubmit,
...rest
}: WizardProps) {
const steps = Children.toArray(children).filter(
(child): child is ReactElement<WizardStepProps> => isValidElement(child),
);
const total = steps.length;
const first = Math.min(Math.max(defaultStep, 0), Math.max(total - 1, 0));
const [current, setCurrent] = useState(first);
const [furthest, setFurthest] = useState(first);
const [announcement, setAnnouncement] = useState("");
const form = useRef<HTMLFormElement>(null);
const moved = useRef(false);
const pendingReport = useRef(false);
const fieldsets = () =>
Array.from(form.current?.querySelectorAll<HTMLFieldSetElement>(":scope > .eq-wizard-body > .eq-wizard-step") ?? []);
function isValid(index: number) {
return controlsOf(fieldsets()[index]).every((control) => control.checkValidity());
}
function show(index: number, report = false) {
moved.current = true;
pendingReport.current = report;
setCurrent(index);
setFurthest((value) => Math.max(value, index));
setAnnouncement(`Step ${index + 1} of ${total}: ${steps[index]?.props.title ?? ""}`);
}
function goTo(target: number) {
if (target === current) return;
if (target < current) return show(target);
for (let index = current; index < target; index++) {
if (!isValid(index)) {
if (index === current) controlsOf(fieldsets()[index]).find((c) => !c.checkValidity())?.reportValidity();
else show(index, true);
return;
}
}
show(target);
}
useEffect(() => {
if (!moved.current) return;
moved.current = false;
const fieldset = fieldsets()[current];
if (!fieldset) return;
if (pendingReport.current) {
pendingReport.current = false;
const invalid = controlsOf(fieldset).find((control) => !control.checkValidity());
if (invalid) return void invalid.reportValidity();
}
const target =
fieldset.querySelector<HTMLElement>(FOCUSABLE) ??
fieldset.querySelector<HTMLElement>(".eq-wizard-legend");
target?.focus();
}, [current]);
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
onSubmit?.(event);
if (event.defaultPrevented) return;
const invalid = controlsOf(fieldsets()[current]).find((control) => !control.checkValidity());
if (invalid) {
event.preventDefault();
invalid.reportValidity();
return;
}
if (current < total - 1) {
event.preventDefault();
show(current + 1);
return;
}
if (onComplete) {
event.preventDefault();
onComplete(new FormData(event.currentTarget));
}
}
const last = current === total - 1;
const optional = steps[current]?.props.optional && !last;
return (
<form
{...rest}
ref={form}
noValidate
aria-label={label}
className={["eq-wizard", className].filter(Boolean).join(" ")}
onSubmit={handleSubmit}
>
<ol className="eq-wizard-steps" aria-label={`${label} steps`}>
{steps.map((step, index) => {
const state = index === current ? "current" : index < furthest ? "done" : index === furthest ? "reached" : "upcoming";
const body = (
<>
<span className="eq-wizard-marker" aria-hidden="true">
{state === "done" ? (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
) : (
index + 1
)}
</span>
<span className="eq-wizard-name">
{step.props.title}
{state === "done" && <span className="eq-wizard-sr">, done</span>}
</span>
</>
);
return (
<li
key={step.key ?? index}
className="eq-wizard-item"
data-state={state}
aria-current={index === current ? "step" : undefined}
>
{state === "done" || state === "reached" ? (
<button type="button" className="eq-wizard-jump" onClick={() => goTo(index)}>
{body}
</button>
) : (
<span className="eq-wizard-jump">{body}</span>
)}
</li>
);
})}
</ol>
<div className="eq-wizard-body">
{steps.map((step, index) => (
<StepContext.Provider key={step.key ?? index} value={{ active: index === current }}>
{step}
</StepContext.Provider>
))}
</div>
<p className="eq-wizard-sr" aria-live="polite">
{announcement}
</p>
<div className="eq-wizard-actions">
<Button
variant="ghost"
className="eq-wizard-back"
disabled={current === 0}
onClick={() => show(current - 1)}
>
Back
</Button>
{optional && (
<Button variant="ghost" onClick={() => show(current + 1)}>
Skip
</Button>
)}
<Button type="submit" variant={last ? "accent" : "solid"}>
{last ? finishLabel : "Next"}
</Button>
</div>
</form>
);
}
export function WizardStep({ title, optional, className, children, ...rest }: WizardStepProps) {
const context = useContext(StepContext);
return (
<fieldset
{...rest}
hidden={context ? !context.active : rest.hidden}
className={["eq-wizard-step", className].filter(Boolean).join(" ")}
>
<legend className="eq-wizard-legend" tabIndex={-1}>
{title}
{optional && <span className="eq-wizard-optional">Optional</span>}
</legend>
{children}
</fieldset>
);
}components/equip/wizard.css233 lines
@layer equip {
.eq-wizard {
container: eq-wizard / inline-size;
position: relative;
display: grid;
margin: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
}
.eq-wizard-steps {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-1) var(--eq-space-3);
margin: 0;
padding: 0;
list-style: none;
}
.eq-wizard-item {
display: flex;
}
.eq-wizard-jump {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 24px;
min-height: 24px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: none;
color: var(--eq-ink-3);
font: inherit;
font-size: var(--eq-text-s);
line-height: 1.3;
text-align: start;
}
button.eq-wizard-jump {
cursor: pointer;
color: var(--eq-ink-2);
transition: color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
button.eq-wizard-jump:hover {
color: var(--eq-ink);
}
}
button.eq-wizard-jump:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-wizard-item[aria-current="step"] .eq-wizard-jump {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-wizard-marker {
flex: none;
display: inline-grid;
place-items: center;
box-sizing: border-box;
width: 20px;
height: 20px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
line-height: 1;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease);
}
.eq-wizard-marker svg {
width: 12px;
height: 12px;
}
.eq-wizard-item[data-state="done"] .eq-wizard-marker {
border-color: transparent;
background: var(--eq-tint-strong);
color: var(--eq-ink);
}
.eq-wizard-item[aria-current="step"] .eq-wizard-marker {
border-color: var(--eq-solid);
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-wizard-body {
display: grid;
min-width: 0;
margin-block: var(--eq-space-4);
}
.eq-wizard-step {
display: grid;
gap: var(--eq-space-3);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.eq-wizard-step[hidden] {
display: none;
}
.eq-wizard-legend {
display: flex;
align-items: center;
gap: var(--eq-space-2);
margin-bottom: var(--eq-space-3);
padding: 0;
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.01em;
}
/* Focus lands here only when a step has no field; the live region has already spoken. */
.eq-wizard-legend:focus {
outline: none;
}
.eq-wizard-optional {
display: inline-flex;
align-items: center;
height: 20px;
padding-inline: var(--eq-space-1);
border-radius: var(--eq-radius-s);
background: var(--eq-tint);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
letter-spacing: 0;
}
/* Bare native controls inside a step get the shared text-entry look. Zero specificity and
unclassed elements only, so any part placed in a step keeps its own styles. */
:where(.eq-wizard-step label:not([class])) {
display: grid;
gap: var(--eq-space-1);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
}
:where(.eq-wizard-step :is(input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="file"], [type="color"]), select, textarea):not([class])) {
box-sizing: border-box;
width: 100%;
height: var(--eq-h-m);
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
:where(.eq-wizard-step textarea:not([class])) {
height: auto;
padding-block: var(--eq-space-2);
}
:where(.eq-wizard-step :is(input, select, textarea):not([class]))::placeholder {
color: var(--eq-ink-3);
}
:where(.eq-wizard-step :is(input, select, textarea):not([class]):focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
:where(.eq-wizard-step :is(input, select, textarea):not([class]):user-invalid) {
border-color: var(--eq-danger);
}
.eq-wizard-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--eq-space-2);
}
.eq-wizard-back {
margin-inline-end: auto;
}
.eq-wizard-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* Narrow: only the current step keeps its name, which stands in for the legend. */
@container eq-wizard (width < 360px) {
.eq-wizard-body {
margin-block: var(--eq-space-2);
}
.eq-wizard-steps {
gap: var(--eq-space-1);
}
.eq-wizard-item:not([aria-current="step"]) .eq-wizard-name,
.eq-wizard-legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-wizard-item:not([aria-current="step"]) .eq-wizard-jump {
justify-content: center;
}
}
}