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
  1. Workspace name
  2. Region
  3. Confirm
Workspace name

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.

  1. Project
  2. Teammates
  3. Alerts
Project

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.

  1. Seats
  2. Billing
  3. Review
Seats

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

PropTypeDefaultWhat it does
labelstringnoneAccessible name of the form and its step list.
onComplete(data: FormData) => voidnoneCalled on Finish with the fields of every step. Without it the form submits natively.
finishLabelstring"Finish"Label of the last step's button.
defaultStepnumber0Zero-based step shown first. Steps before it count as done.
...restComponentProps<"form">noneEvery native form attribute, including action, onSubmit and ref.
WizardStep titlestringnoneShown in the step list and as the step's legend.
WizardStep optionalbooleanfalseAdds a Skip button that moves on without checking the step.
WizardStep ...restComponentProps<"fieldset">noneEvery native fieldset attribute. Children are the step's fields.

Anatomy

root.eq-wizardThe one form; a size container.
steps.eq-wizard-stepsOrdered list; the current item has aria-current="step".
item.eq-wizard-itemdata-state is done, current, reached or upcoming.
jump.eq-wizard-jumpA button for steps you can return to, a span otherwise.
marker.eq-wizard-markerStep number, or a check when done.
name.eq-wizard-name
body.eq-wizard-body
step.eq-wizard-stepA fieldset; inactive ones are hidden.
legend.eq-wizard-legend
optional.eq-wizard-optionalBadge on an optional step's legend.
actions.eq-wizard-actions
back.eq-wizard-back
visually hidden.eq-wizard-srThe live region and the done suffix in the step list.

Keyboard

EnterIn a field, goes to the next step after checking this one; on the last step, finishes.
TabShift+TabMoves through the step list, the current step's fields and the actions.
EnterSpaceOn 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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index