Multi Step Wizard
Try in playground →opens this recipe’s markup and SCSS, live-compiled
Live: a 3-step form. Step 1 requires an email and a 6+ character password before Next is allowed; Back never re-validates the step you're leaving.
Step 1 of 3: Account
Use this when
You're building an onboarding flow or a checkout — several logical steps, each with its own fields, navigated with Back/Next and a final Submit. This recipe is the orchestration logic (which step is current, whether the current step is allowed to advance, how the error summary reads) — the visual step indicator itself is cia's existing stepper mixin, cross-linked rather than restyled. Field-level validation inside a given step is form-validation-html5 or whichever validation recipe you're already using — this recipe wraps around it, one step at a time.
Structure (raw HTML)
<div data-cia-recipe="multi-step-wizard">
<ol data-slot="stepper" aria-hidden="true">
<li data-status="completed"><span data-slot="circle">✓</span><span data-slot="label">Account</span></li>
<li data-status="active"><span data-slot="circle">2</span><span data-slot="label">Profile</span></li>
<li data-status="upcoming"><span data-slot="circle">3</span><span data-slot="label">Review</span></li>
</ol>
<p data-slot="step-status" aria-live="polite">Step 2 of 3: Profile</p>
<div data-slot="error-summary" role="alert" hidden>
Please fix the highlighted fields before continuing.
</div>
<div data-slot="step" role="group" aria-label="Profile">
<!-- current step's fields go here -->
</div>
<div data-slot="nav">
<button type="button" data-slot="back">Back</button>
<button type="button" data-slot="next">Next</button>
<!-- data-slot="submit" replaces data-slot="next" only on the last step -->
</div>
</div>
Notes on the markup:
[data-slot="stepper"]isaria-hidden="true"— it's a visual progress indicator, and[data-slot="step-status"]'saria-live="polite"text ("Step 2 of 3: Profile") is what actually gets announced on a step change, once, in plain language. Exposing both would double-announce the same information in two different shapes.- The error summary element exists in the markup
hiddenby default and is only revealed (and itsrole="alert"allowed to fire) on a failed Next/Submit attempt — never shown pre-emptively. [data-slot="step"]is a singlerole="group"region whose content is swapped per step, not three parallel step containers toggled withhidden— screen reader users landing in the group hear one label ("Profile"), not stale content from a step they've left.- The Back/Next/Submit row lives outside
[data-slot="step"]so it never gets replaced along with step content.
Styling (cia mixins)
// MultiStepWizard.module.scss
@use 'css-is-awesome/api' as cia;
[data-slot="stepper"] {
@include cia.stepper;
margin-block-end: cia.space(5);
}
[data-slot="step-status"] {
@include cia.sr-only;
}
[data-slot="error-summary"] {
@include cia.pad(3);
border-radius: cia.radius(md);
background: cia.color(error-subtle);
color: cia.color(error-text);
margin-block-end: cia.space(4);
}
[data-slot="step"] {
@include cia.stack($gap: 4);
margin-block-end: cia.space(5);
}
[data-slot="nav"] {
@include cia.toolbar($gap: 3);
}
[data-slot="back"] { @include cia.btn(ghost); }
[data-slot="next"],
[data-slot="submit"] { @include cia.btn(primary); }
Interactivity
- State:
currentStep(an index), plus each step's own validity (however the step's own fields report it — avalidate()callback per step is the simplest contract). - Back: always allowed, disabled only on step 0. Going back never runs the leaving step's own validator — a half-filled step never gets marked errored just because the user retreated from it. It's validated again on the way forward, and again on Submit.
- Next: runs the current step's validator. Passes → advance
currentStep, move focus to the new step's first field, announce the new step via[data-slot="step-status"]. Fails → reveal the error summary, keep the step, don't advance. - Submit (replaces Next only on the last step): re-runs every step's validator, not only the ones visited — a step a user skipped past via a prior "allow skip on error" setting still has to pass before final submit. Any failure blocks submission and reveals the summary; all pass → fire the real submit handler.
- Rendered button, not two DOM nodes: Next and Submit are the same slot with a different label/handler depending on
currentStep === lastStep, so there's exactly one primary action button in the tab order at any time, never a hidden duplicate.
A11y checklist
- The visual stepper is
aria-hidden; a separatearia-live="polite"text element is what's actually announced on step change, in one sentence, not the raw list-item markup (WCAG 2.2 SC 4.1.3 Status Messages) - The current step's content is a single
role="group"with anaria-labelnaming that step, replaced per-step rather than several regions toggled withhidden(WAI-ARIA: group role) - On advancing to a new step, focus moves to that step's first focusable field, not left on the Next button that's no longer in the same visual position
- The error summary is
role="alert"and only fires when a validation attempt actually fails — never present-but-hidden-by-CSS in a way a screen reader would still read - The active step's stepper indicator (even though
aria-hidden) is styled distinctly and isn't the only way sighted users can tell which step is current — the step-status text is visible too, not screen-reader-only
Framework examples
React
"use client";
import { useState } from "react";
import styles from "./MultiStepWizard.module.scss";
type Step = { label: string; content: React.ReactNode; validate?: () => boolean };
export default function MultiStepWizard({ steps, onSubmit }: { steps: Step[]; onSubmit: () => void }) {
const [current, setCurrent] = useState(0);
const [showErrors, setShowErrors] = useState(false);
const isLast = current === steps.length - 1;
function handleNext() {
const step = steps[current];
if (step.validate && !step.validate()) {
setShowErrors(true);
return;
}
setShowErrors(false);
setCurrent((c) => Math.min(c + 1, steps.length - 1));
}
function handleSubmit() {
const allValid = steps.every((s) => !s.validate || s.validate());
if (!allValid) {
setShowErrors(true);
return;
}
onSubmit();
}
return (
<div>
<ol className={styles.stepper} aria-hidden="true">
{steps.map((s, i) => (
<li key={s.label} data-status={i < current ? "completed" : i === current ? "active" : "upcoming"}>
<span data-slot="circle">{i < current ? "✓" : i + 1}</span>
<span data-slot="label">{s.label}</span>
</li>
))}
</ol>
<p className={styles.stepStatus} aria-live="polite">
Step {current + 1} of {steps.length}: {steps[current].label}
</p>
{showErrors && (
<div className={styles.errorSummary} role="alert">
Please fix the highlighted fields before continuing.
</div>
)}
<div className={styles.step} role="group" aria-label={steps[current].label}>
{steps[current].content}
</div>
<div className={styles.nav}>
<button type="button" disabled={current === 0} onClick={() => setCurrent((c) => Math.max(0, c - 1))}>
Back
</button>
{isLast ? (
<button type="button" onClick={handleSubmit}>
Submit
</button>
) : (
<button type="button" onClick={handleNext}>
Next
</button>
)}
</div>
</div>
);
}
Vue
<script setup>
import { ref, computed } from "vue";
const props = defineProps<{ steps: { label: string; validate?: () => boolean }[] }>();
const emit = defineEmits(["submit"]);
const current = ref(0);
const showErrors = ref(false);
const isLast = computed(() => current.value === props.steps.length - 1);
function next() {
const step = props.steps[current.value];
if (step.validate && !step.validate()) {
showErrors.value = true;
return;
}
showErrors.value = false;
current.value = Math.min(current.value + 1, props.steps.length - 1);
}
function submit() {
const allValid = props.steps.every((s) => !s.validate || s.validate());
if (!allValid) {
showErrors.value = true;
return;
}
emit("submit");
}
</script>
<template>
<div>
<ol class="stepper" aria-hidden="true">
<li v-for="(s, i) in steps" :key="s.label" :data-status="i < current ? 'completed' : i === current ? 'active' : 'upcoming'">
<span data-slot="circle">{{ i < current ? "✓" : i + 1 }}</span>
<span data-slot="label">{{ s.label }}</span>
</li>
</ol>
<p class="step-status" aria-live="polite">Step {{ current + 1 }} of {{ steps.length }}: {{ steps[current].label }}</p>
<div v-if="showErrors" class="error-summary" role="alert">Please fix the highlighted fields before continuing.</div>
<div class="step" role="group" :aria-label="steps[current].label">
<slot :name="`step-${current}`" />
</div>
<div class="nav">
<button type="button" :disabled="current === 0" @click="current = Math.max(0, current - 1)">Back</button>
<button type="button" @click="isLast ? submit() : next()">{{ isLast ? "Submit" : "Next" }}</button>
</div>
</div>
</template>
Svelte
<script>
export let steps; // { label, validate? }[]
export let onSubmit = () => {};
let current = 0;
let showErrors = false;
$: isLast = current === steps.length - 1;
function next() {
const step = steps[current];
if (step.validate && !step.validate()) {
showErrors = true;
return;
}
showErrors = false;
current = Math.min(current + 1, steps.length - 1);
}
function submit() {
const allValid = steps.every((s) => !s.validate || s.validate());
if (!allValid) {
showErrors = true;
return;
}
onSubmit();
}
</script>
<ol class="stepper" aria-hidden="true">
{#each steps as s, i}
<li data-status={i < current ? "completed" : i === current ? "active" : "upcoming"}>
<span data-slot="circle">{i < current ? "✓" : i + 1}</span>
<span data-slot="label">{s.label}</span>
</li>
{/each}
</ol>
<p class="step-status" aria-live="polite">Step {current + 1} of {steps.length}: {steps[current].label}</p>
{#if showErrors}
<div class="error-summary" role="alert">Please fix the highlighted fields before continuing.</div>
{/if}
<div class="step" role="group" aria-label={steps[current].label}>
<slot name="step" {current} />
</div>
<div class="nav">
<button type="button" disabled={current === 0} on:click={() => (current = Math.max(0, current - 1))}>Back</button>
<button type="button" on:click={isLast ? submit : next}>{isLast ? "Submit" : "Next"}</button>
</div>
Vanilla (Web Component)
class MultiStepWizard extends HTMLElement {
constructor() {
super();
this.steps = []; // set by the consumer: [{ label, render(container), validate }]
this.current = 0;
}
connectedCallback() {
this.innerHTML = `
<ol data-slot="stepper" aria-hidden="true"></ol>
<p data-slot="step-status" aria-live="polite"></p>
<div data-slot="error-summary" role="alert" hidden>Please fix the highlighted fields before continuing.</div>
<div data-slot="step" role="group"></div>
<div data-slot="nav">
<button type="button" data-slot="back">Back</button>
<button type="button" data-slot="next">Next</button>
</div>
`;
this.querySelector('[data-slot="back"]').addEventListener("click", () => this._go(this.current - 1, false));
this.querySelector('[data-slot="next"]').addEventListener("click", () => this._advance());
this._render();
}
_advance() {
const step = this.steps[this.current];
if (step.validate && !step.validate()) {
this.querySelector('[data-slot="error-summary"]').hidden = false;
return;
}
this.querySelector('[data-slot="error-summary"]').hidden = true;
if (this.current === this.steps.length - 1) {
const allValid = this.steps.every((s) => !s.validate || s.validate());
if (allValid) this.dispatchEvent(new CustomEvent("wizard-submit"));
return;
}
this._go(this.current + 1, true);
}
_go(i) {
this.current = Math.max(0, Math.min(i, this.steps.length - 1));
this._render();
}
_render() {
const stepper = this.querySelector('[data-slot="stepper"]');
stepper.innerHTML = this.steps
.map(
(s, i) =>
`<li data-status="${i < this.current ? "completed" : i === this.current ? "active" : "upcoming"}">
<span data-slot="circle">${i < this.current ? "✓" : i + 1}</span>
<span data-slot="label">${s.label}</span>
</li>`,
)
.join("");
this.querySelector('[data-slot="step-status"]').textContent =
`Step ${this.current + 1} of ${this.steps.length}: ${this.steps[this.current].label}`;
const stepEl = this.querySelector('[data-slot="step"]');
stepEl.setAttribute("aria-label", this.steps[this.current].label);
stepEl.innerHTML = "";
this.steps[this.current].render(stepEl);
this.querySelector('[data-slot="back"]').disabled = this.current === 0;
this.querySelector('[data-slot="next"]').textContent = this.current === this.steps.length - 1 ? "Submit" : "Next";
}
}
customElements.define("multi-step-wizard", MultiStepWizard);
Pitfalls
- Don't validate the step being left when the user clicks Back. It punishes retreating from a half-filled step with an error state the user didn't ask for yet — only forward navigation and Submit should trigger validation.
- Don't render all steps' DOM at once and toggle visibility. A hidden step's fields are still in the tab order in some browsers unless every one is carefully
inerted, and a single swapped[data-slot="step"]sidesteps the whole problem. - Don't let Submit trust only the currently-visited steps. A user who used a "skip on error" affordance to jump ahead can reach the last step with an earlier step still invalid — Submit must re-check everything.
Related recipes
form-validation-html5— what a single step's own field validation should look likeauth-flow— the submit/loading/error handling pattern once the wizard's final step actually submits somewhere