UI Components Medium
Multi-step Wizard
A keyboard-friendly three-step wizard with validation and a progress indicator.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--card: #141b2e;
--line: #26324f;
--txt: #e7eaf1;
--muted: #8d97ab;
--accent: #78a9ff;
--bad: #ff7a7a;
--ok: #5fd6a4;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(120% 100% at 50% 0%, #16203a 0%, var(--bg) 60%);
color: var(--txt);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.demo { width: 100%; padding: clamp(1rem, 4vw, 3rem); display: grid; place-items: center; }
.wizard {
width: min(480px, 100%);
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 24px;
box-shadow: 0 24px 70px rgb(0 0 0 / 0.45);
}
/* stepper */
.steps {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 0 0 14px;
padding: 0;
list-style: none;
}
.step { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
.step__dot {
width: 26px; height: 26px;
display: grid; place-items: center;
border-radius: 50%;
border: 1px solid var(--line);
background: #0f1526;
font-size: 12px; font-weight: 600;
transition: background .25s, color .25s, border-color .25s;
}
.step.is-current { color: var(--txt); }
.step.is-current .step__dot {
border-color: var(--accent); color: var(--accent);
box-shadow: 0 0 0 4px rgb(120 169 255 / 0.14);
}
.step.is-done .step__dot {
background: var(--ok); border-color: var(--ok);
color: transparent; position: relative;
}
.step.is-done .step__dot::after {
content: "✓"; position: absolute; inset: 0;
display: grid; place-items: center; color: #06281c; font-size: 13px;
}
.bar { height: 5px; border-radius: 99px; background: #0f1526; overflow: hidden; margin-bottom: 22px; }
.bar__fill {
height: 100%; width: 33.33%;
background: linear-gradient(90deg, var(--accent), #a78bfa);
transition: width .35s cubic-bezier(.4, 0, .2, 1);
}
/* fields */
.panel { display: grid; gap: 16px; }
.panel[hidden] { display: none; }
.field { display: grid; gap: 6px; }
label { font-size: 13px; color: var(--muted); }
input {
width: 100%;
padding: 11px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: #0f1526;
color: var(--txt);
font: inherit;
transition: border-color .18s, box-shadow .18s;
}
input:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgb(120 169 255 / 0.2); }
input[aria-invalid="true"] { border-color: var(--bad); }
.err { margin: 0; min-height: 16px; font-size: 12px; color: var(--bad); }
/* review */
.review { margin: 0; display: grid; grid-template-columns: 110px 1fr; gap: 8px 12px; font-size: 14px; }
.review dt { color: var(--muted); }
.review dd { margin: 0; overflow-wrap: anywhere; }
.done { color: var(--ok); font-size: 14px; min-height: 20px; margin: 14px 0 0; }
/* nav */
.nav { display: flex; gap: 10px; margin-top: 24px; }
.btn {
flex: 1;
padding: 11px 16px;
border-radius: 10px;
border: 1px solid var(--line);
background: #0f1526;
color: var(--txt);
font: inherit; font-weight: 600;
cursor: pointer;
transition: background .18s, transform .12s;
}
.btn:hover:not(:disabled) { background: #1b2542; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn--primary { background: var(--accent); border-color: var(--accent); color: #08172e; }
.btn--primary:hover:not(:disabled) { background: #9dc1ff; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.live { margin: 12px 0 0; font-size: 12px; color: var(--muted); min-height: 16px; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}/* Multi-step wizard: per-step validation, progress, keyboard-friendly focus. */
(() => {
const form = document.getElementById("wizard");
if (!form) return;
const panels = [...form.querySelectorAll(".panel")];
const stepEls = [...form.querySelectorAll(".step")];
const barFill = document.getElementById("barFill");
const bar = document.getElementById("bar");
const backBtn = document.getElementById("back");
const nextBtn = document.getElementById("next");
const live = document.getElementById("live");
const reviewList = document.getElementById("review");
const doneMsg = document.getElementById("done");
const LABELS = { email: "Email", pass: "Password", name: "Full name", phone: "Phone" };
const RULES = {
email: (v) => (/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v) ? "" : "Enter a valid email address."),
pass: (v) => (v.length >= 8 ? "" : "Use at least 8 characters."),
name: (v) => (v.trim().length >= 2 ? "" : "Tell us your name."),
phone: (v) => (v.replace(/\D/g, "").length >= 7 ? "" : "Enter at least 7 digits."),
};
let index = 0; // 0-based current step
const total = panels.length;
function fieldsOf(step) {
return [...panels[step].querySelectorAll("input")];
}
function validateField(input, silent) {
const rule = RULES[input.name];
const msg = rule ? rule(input.value) : "";
const err = document.getElementById(`${input.id}-err`);
if (!silent || msg === "") {
if (err) err.textContent = msg;
input.setAttribute("aria-invalid", msg ? "true" : "false");
}
return msg === "";
}
function validateStep(step) {
const fields = fieldsOf(step);
let firstBad = null;
fields.forEach((f) => {
if (!validateField(f) && !firstBad) firstBad = f;
});
if (firstBad) {
firstBad.focus();
live.textContent = "Please fix the highlighted fields.";
return false;
}
return true;
}
function buildReview() {
const data = new FormData(form);
reviewList.innerHTML = "";
for (const key of ["email", "pass", "name", "phone"]) {
const dt = document.createElement("dt");
dt.textContent = LABELS[key];
const dd = document.createElement("dd");
const raw = String(data.get(key) ?? "");
dd.textContent = key === "pass" ? "•".repeat(raw.length) : raw;
reviewList.append(dt, dd);
}
}
function render(focusFirst) {
panels.forEach((p, i) => {
p.hidden = i !== index;
});
stepEls.forEach((s, i) => {
s.classList.toggle("is-current", i === index);
s.classList.toggle("is-done", i < index);
if (i === index) s.setAttribute("aria-current", "step");
else s.removeAttribute("aria-current");
});
barFill.style.width = `${((index + 1) / total) * 100}%`;
bar.setAttribute("aria-valuenow", String(index + 1));
backBtn.disabled = index === 0;
nextBtn.textContent = index === total - 1 ? "Submit" : "Next";
live.textContent = `Step ${index + 1} of ${total}`;
if (index === total - 1) buildReview();
if (focusFirst) {
const target = fieldsOf(index)[0] || panels[index];
if (target.focus) target.focus();
}
}
function go(next) {
index = Math.min(total - 1, Math.max(0, next));
doneMsg.textContent = "";
render(true);
}
// live re-validation once a field has been marked invalid
form.addEventListener("input", (e) => {
const t = e.target;
if (t instanceof HTMLInputElement && t.getAttribute("aria-invalid") === "true") {
validateField(t, true);
}
});
form.addEventListener(
"blur",
(e) => {
const t = e.target;
if (t instanceof HTMLInputElement && t.value !== "") validateField(t);
},
true
);
backBtn.addEventListener("click", () => go(index - 1));
form.addEventListener("submit", (e) => {
e.preventDefault();
if (index < total - 1) {
if (!validateStep(index)) return;
go(index + 1);
return;
}
// final step: re-check everything before "sending"
for (let s = 0; s < total; s++) {
if (!validateStep(s)) {
go(s);
return;
}
}
doneMsg.textContent = "All set — payload validated and ready to submit.";
live.textContent = "Wizard complete.";
nextBtn.disabled = true;
});
// Ctrl/Cmd + arrows to move between steps without touching the mouse
form.addEventListener("keydown", (e) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key === "ArrowRight" && index < total - 1) {
e.preventDefault();
if (validateStep(index)) go(index + 1);
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault();
go(index - 1);
}
});
render(false);
})();<link rel="stylesheet" href="style.css" />
<main class="demo" data-demo="Multi-step Wizard">
<form class="wizard" id="wizard" novalidate>
<ol class="steps" id="steps">
<li class="step is-current" data-step="1" aria-current="step">
<span class="step__dot">1</span><span class="step__label">Account</span>
</li>
<li class="step" data-step="2">
<span class="step__dot">2</span><span class="step__label">Profile</span>
</li>
<li class="step" data-step="3">
<span class="step__dot">3</span><span class="step__label">Review</span>
</li>
</ol>
<div class="bar" role="progressbar" id="bar" aria-valuemin="1" aria-valuemax="3" aria-valuenow="1" aria-label="Wizard progress">
<div class="bar__fill" id="barFill"></div>
</div>
<section class="panel" data-panel="1" aria-label="Account">
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" inputmode="email"
autocomplete="email" required aria-describedby="email-err" />
<p class="err" id="email-err" role="alert"></p>
</div>
<div class="field">
<label for="pass">Password (min 8)</label>
<input id="pass" name="pass" type="password" minlength="8"
autocomplete="new-password" required aria-describedby="pass-err" />
<p class="err" id="pass-err" role="alert"></p>
</div>
</section>
<section class="panel" data-panel="2" hidden aria-label="Profile">
<div class="field">
<label for="name">Full name</label>
<input id="name" name="name" type="text" required aria-describedby="name-err" />
<p class="err" id="name-err" role="alert"></p>
</div>
<div class="field">
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" inputmode="tel"
pattern="[0-9 +()\-]{7,}" required aria-describedby="phone-err" />
<p class="err" id="phone-err" role="alert"></p>
</div>
</section>
<section class="panel" data-panel="3" hidden aria-label="Review">
<dl class="review" id="review"></dl>
<p class="done" id="done" role="status"></p>
</section>
<div class="nav">
<button type="button" class="btn" id="back" disabled>Back</button>
<button type="submit" class="btn btn--primary" id="next">Next</button>
</div>
<p class="live" id="live" aria-live="polite">Step 1 of 3</p>
</form>
</main>
<script src="script.js"></script>import { useState } from "react";
export function FormWizardProgress() {
const [value, setValue] = useState("");
return (
<form className="demo" onSubmit={(event) => event.preventDefault()}>
<h2>Multi-step Wizard</h2>
<label>
{value.length ? "Ready" : "Enter a value"}
<input value={value} onChange={(event) => setValue(event.target.value)} />
</label>
<button type="submit">Continue</button>
</form>
);
}Multi-step Wizard
A keyboard-friendly three-step wizard with validation and a progress indicator.
Support notes
Validation is local and progressively enhanced. Keep labels, live messages, and inputmode attributes intact when adapting these patterns to a real backend.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.