Form — Branching wizard (conditional steps)
A multi-step account wizard whose path forks on the answers you give. Pick Personal and you get three lean steps; pick Business and the wizard inserts extra company and VAT/tax steps, recomputing its step sequence on the fly. The progress count, breadcrumb rail and Back button always reflect the real branch length, every step validates inline with aria-invalid messaging, a nested VAT field reveals conditionally, and the run ends on a summary tailored to the chosen path.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(900px 500px at 85% -10%, rgba(91, 91, 240, 0.08), transparent 60%),
radial-gradient(700px 420px at -10% 110%, rgba(0, 180, 166, 0.07), transparent 60%),
var(--bg);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 40px 20px;
}
.wrap {
width: 100%;
max-width: 620px;
}
/* ── Masthead ── */
.masthead {
text-align: center;
margin-bottom: 22px;
}
.kicker {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--brand-d);
background: var(--brand-50);
padding: 5px 12px;
border-radius: 999px;
margin-bottom: 12px;
}
.title {
font-size: clamp(1.5rem, 4vw, 1.95rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
}
.sub {
margin-top: 8px;
color: var(--muted);
font-size: 0.95rem;
max-width: 46ch;
margin-left: auto;
margin-right: auto;
}
/* ── Card ── */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 26px;
position: relative;
overflow: hidden;
}
/* ── Rail / progress ── */
.rail {
margin-bottom: 22px;
}
.rail-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.rail-count {
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-2);
}
.rail-count span {
color: var(--brand-d);
font-weight: 700;
}
.rail-path {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-soft);
padding: 3px 10px;
border-radius: 999px;
}
.track {
height: 6px;
background: var(--brand-50);
border-radius: 999px;
overflow: hidden;
}
.track-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--accent));
transition: width 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
.crumbs {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.crumbs li {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.74rem;
font-weight: 600;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 10px 4px 6px;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.crumbs li .num {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--line);
color: var(--ink-2);
font-size: 0.68rem;
font-weight: 700;
flex: none;
}
.crumbs li.done {
color: var(--ok);
border-color: rgba(47, 158, 111, 0.3);
background: rgba(47, 158, 111, 0.08);
}
.crumbs li.done .num {
background: var(--ok);
color: var(--white);
}
.crumbs li.current {
color: var(--brand-700);
border-color: var(--brand);
background: var(--brand-50);
}
.crumbs li.current .num {
background: var(--brand);
color: var(--white);
}
/* ── Screen-reader status ── */
.sr-status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ── Steps ── */
.stage {
position: relative;
}
.step[hidden] {
display: none;
}
.step {
border: 0;
min-width: 0;
animation: stepIn 0.34s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes stepIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-title {
font-size: 1.18rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--ink);
padding: 0;
}
.step-sub {
color: var(--muted);
font-size: 0.9rem;
margin: 4px 0 18px;
}
/* ── Fields ── */
.field {
margin-bottom: 16px;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.lbl {
display: block;
font-size: 0.84rem;
font-weight: 600;
color: var(--ink-2);
margin-bottom: 6px;
}
.req {
color: var(--danger);
}
.opt {
color: var(--muted);
font-weight: 500;
}
.inp {
width: 100%;
font: inherit;
font-size: 0.94rem;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 11px 13px;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
.inp::placeholder {
color: #a4a9c1;
}
.inp:hover:not(:disabled) {
border-color: var(--muted);
}
.inp:focus-visible,
.inp:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.16);
}
.sel {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236c7393' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 13px center;
padding-right: 36px;
cursor: pointer;
}
.inp:disabled {
background: var(--bg);
color: var(--muted);
cursor: not-allowed;
border-color: var(--line);
}
.help {
font-size: 0.78rem;
color: var(--muted);
margin-top: 5px;
}
.field-err {
font-size: 0.78rem;
font-weight: 600;
color: var(--danger);
margin-top: 5px;
min-height: 0;
display: none;
align-items: center;
gap: 5px;
}
.field-err::before {
content: "";
width: 14px;
height: 14px;
flex: none;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23d4503e' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='12'/%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'/%3E%3C/svg%3E") center / contain no-repeat;
}
.field-err.show {
display: flex;
}
/* invalid / valid field decoration */
.inp[aria-invalid="true"] {
border-color: var(--danger);
}
.inp[aria-invalid="true"]:focus {
box-shadow: 0 0 0 4px rgba(212, 80, 62, 0.16);
}
.inp.is-valid {
border-color: var(--ok);
}
.inp.is-valid:focus {
box-shadow: 0 0 0 4px rgba(47, 158, 111, 0.16);
}
/* ── Choice cards (radio) ── */
.choice-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.choice {
position: relative;
display: block;
cursor: pointer;
}
.choice input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.choice-body {
display: block;
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 16px;
background: var(--white);
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s, transform 0.18s;
}
.choice:hover .choice-body {
border-color: var(--brand);
transform: translateY(-2px);
}
.choice input:focus-visible + .choice-body {
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.16);
}
.choice input:checked + .choice-body {
border-color: var(--brand);
background: var(--brand-50);
box-shadow: var(--sh-1);
}
.choice-ico {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--r-sm);
background: var(--brand-50);
color: var(--brand-d);
margin-bottom: 10px;
transition: background 0.18s, color 0.18s;
}
.choice input:checked + .choice-body .choice-ico {
background: var(--brand);
color: var(--white);
}
.choice-name {
display: block;
font-weight: 700;
font-size: 0.98rem;
color: var(--ink);
}
.choice-desc {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-top: 2px;
}
/* ── Segmented radio ── */
.seg {
display: inline-flex;
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 4px;
gap: 4px;
background: var(--bg);
}
.seg-opt {
position: relative;
cursor: pointer;
}
.seg-opt input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.seg-opt span {
display: block;
padding: 7px 20px;
border-radius: var(--r-sm);
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-2);
transition: background 0.18s, color 0.18s;
}
.seg-opt input:focus-visible + span {
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.3);
}
.seg-opt input:checked + span {
background: var(--brand);
color: var(--white);
box-shadow: var(--sh-1);
}
/* ── Summary (review) ── */
.summary {
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg);
padding: 4px 16px;
margin-bottom: 18px;
}
.summary .row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 11px 0;
border-bottom: 1px solid var(--line);
}
.summary .row:last-child {
border-bottom: 0;
}
.summary dt {
font-size: 0.82rem;
color: var(--muted);
font-weight: 500;
flex: none;
}
.summary dd {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
text-align: right;
word-break: break-word;
}
.summary .group-head {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-d);
padding: 12px 0 4px;
border-bottom: 1px solid var(--line);
}
/* ── Terms ── */
.terms {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 0.88rem;
color: var(--ink-2);
cursor: pointer;
padding: 12px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
transition: border-color 0.18s, background 0.18s;
}
.terms:hover {
border-color: var(--muted);
}
.terms input {
margin-top: 2px;
width: 18px;
height: 18px;
accent-color: var(--brand);
flex: none;
cursor: pointer;
}
.terms input:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ── Nav ── */
.nav {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.nav-spacer {
flex: 1;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-size: 0.92rem;
font-weight: 600;
border-radius: var(--r-md);
padding: 11px 20px;
cursor: pointer;
border: 1.5px solid transparent;
transition: background 0.18s, border-color 0.18s, transform 0.12s, box-shadow 0.18s, color 0.18s;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.28);
}
.btn.primary {
background: var(--brand);
color: var(--white);
box-shadow: var(--sh-1);
}
.btn.primary:hover {
background: var(--brand-d);
}
.btn.ghost {
background: var(--white);
color: var(--ink-2);
border-color: var(--line-2);
}
.btn.ghost:hover {
border-color: var(--muted);
background: var(--bg);
}
/* ── Success state ── */
.done {
text-align: center;
padding: 26px 10px 12px;
animation: stepIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.done-ico {
display: grid;
place-items: center;
width: 68px;
height: 68px;
margin: 0 auto 18px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--ok);
animation: pop 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes pop {
0% {
transform: scale(0.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.done-title {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
}
.done-msg {
color: var(--muted);
font-size: 0.95rem;
margin: 8px auto 22px;
max-width: 40ch;
}
/* ── Footnote ── */
.footnote {
text-align: center;
font-size: 0.76rem;
color: var(--muted);
margin-top: 18px;
}
/* ── Toast ── */
.toast-host {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 50;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 9px;
background: var(--ink);
color: var(--white);
font-size: 0.86rem;
font-weight: 500;
padding: 11px 16px;
border-radius: var(--r-md);
box-shadow: var(--sh-2);
animation: toastIn 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.is-out {
animation: toastOut 0.26s ease forwards;
}
.toast .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
.toast.warn .dot {
background: var(--warn);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(8px);
}
}
/* ── Responsive ── */
@media (max-width: 520px) {
body {
padding: 22px 14px;
}
.card {
padding: 18px;
border-radius: var(--r-md);
}
.grid-2,
.choice-grid {
grid-template-columns: 1fr;
}
.rail-head {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.seg {
width: 100%;
}
.seg-opt {
flex: 1;
}
.seg-opt span {
text-align: center;
}
.nav {
flex-wrap: wrap;
}
.btn {
flex: 1;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------------------------------------------------------------------
* Branching wizard
* The set of steps shown depends on the user's answers. State tracks the
* collected answers plus a computed `sequence` (the active branch), so the
* progress count, crumbs and Back button always reflect the real path.
* ------------------------------------------------------------------- */
var form = document.getElementById("wizardForm");
var stage = document.getElementById("stage");
var steps = Array.prototype.slice.call(stage.querySelectorAll(".step"));
var backBtn = document.getElementById("backBtn");
var nextBtn = document.getElementById("nextBtn");
var nextLabel = document.getElementById("nextLabel");
var crumbsEl = document.getElementById("crumbs");
var trackFill = document.getElementById("trackFill");
var track = document.getElementById("track");
var stepNowEl = document.getElementById("stepNow");
var stepTotalEl = document.getElementById("stepTotal");
var railPathEl = document.getElementById("railPath");
var liveStatus = document.getElementById("liveStatus");
var doneEl = document.getElementById("done");
var doneMsg = document.getElementById("doneMsg");
var restartBtn = document.getElementById("restartBtn");
var toastHost = document.getElementById("toastHost");
/* Step metadata. `branch: "business"` steps only appear on the business path. */
var META = {
"account-type": { label: "Account", branch: "all" },
profile: { label: "Profile", branch: "all" },
company: { label: "Company", branch: "business" },
tax: { label: "Tax", branch: "business" },
review: { label: "Review", branch: "all" }
};
var state = {
answers: {},
sequence: [],
index: 0
};
/* --------------------------- step sequence --------------------------- */
function computeSequence() {
var business = state.answers.accountType === "business";
var seq = [];
Object.keys(META).forEach(function (key) {
var b = META[key].branch;
if (b === "all" || (b === "business" && business)) seq.push(key);
});
return seq;
}
/* Keep index pointed at the same step key when the branch changes length. */
function rebuildSequence(currentKey) {
state.sequence = computeSequence();
var i = state.sequence.indexOf(currentKey);
state.index = i === -1 ? Math.min(state.index, state.sequence.length - 1) : i;
}
function currentKey() {
return state.sequence[state.index];
}
function getStep(key) {
return steps.filter(function (s) {
return s.getAttribute("data-step") === key;
})[0];
}
/* ------------------------------- render ------------------------------ */
function render() {
var key = currentKey();
var total = state.sequence.length;
steps.forEach(function (s) {
s.hidden = s.getAttribute("data-step") !== key;
});
stepNowEl.textContent = String(state.index + 1);
stepTotalEl.textContent = String(total);
railPathEl.textContent =
state.answers.accountType === "business" ? "Business path" : "Personal path";
var pct = total <= 1 ? 0 : (state.index / (total - 1)) * 100;
trackFill.style.width = pct + "%";
track.setAttribute("aria-valuenow", String(Math.round(pct)));
renderCrumbs();
backBtn.hidden = state.index === 0;
var isLast = state.index === total - 1;
nextLabel.textContent = isLast ? "Create account" : "Continue";
if (key === "review") buildSummary();
/* Focus the first sensible target on the new step for keyboard users. */
var stepEl = getStep(key);
var focusable = stepEl.querySelector(
"input:not([type=hidden]), select, textarea, button"
);
if (focusable) {
// Defer so the step's enter animation doesn't fight the scroll.
window.requestAnimationFrame(function () {
focusable.focus({ preventScroll: false });
});
}
}
function renderCrumbs() {
crumbsEl.innerHTML = "";
state.sequence.forEach(function (key, i) {
var li = document.createElement("li");
if (i < state.index) li.className = "done";
else if (i === state.index) {
li.className = "current";
li.setAttribute("aria-current", "step");
}
var num = document.createElement("span");
num.className = "num";
num.textContent = i < state.index ? "✓" : String(i + 1);
var label = document.createTextNode(META[key].label);
li.appendChild(num);
li.appendChild(label);
crumbsEl.appendChild(li);
});
}
/* ----------------------------- summary ------------------------------- */
function buildSummary() {
var a = state.answers;
var business = a.accountType === "business";
document.getElementById("reviewPathName").textContent = business
? "business account"
: "personal account";
var rows = [];
rows.push({ head: "Account" });
rows.push(["Type", business ? "Business" : "Personal"]);
rows.push(["Name", a.fullName || "—"]);
rows.push(["Email", a.email || "—"]);
rows.push(["Password", a.password ? "•".repeat(Math.min(a.password.length, 12)) : "—"]);
if (business) {
rows.push({ head: "Company" });
rows.push(["Legal name", a.companyName || "—"]);
rows.push(["Team size", a.companySize || "—"]);
rows.push(["Country", countryName(a.country)]);
rows.push({ head: "Tax & billing" });
rows.push(["VAT-registered", a.vatRegistered === "yes" ? "Yes" : "No"]);
if (a.vatRegistered === "yes") rows.push(["VAT number", a.vatNumber || "—"]);
rows.push(["Billing email", a.billingEmail || a.email || "—"]);
}
var dl = document.getElementById("summary");
dl.innerHTML = "";
rows.forEach(function (r) {
if (r.head) {
var h = document.createElement("div");
h.className = "group-head";
h.textContent = r.head;
dl.appendChild(h);
return;
}
var row = document.createElement("div");
row.className = "row";
var dt = document.createElement("dt");
dt.textContent = r[0];
var dd = document.createElement("dd");
dd.textContent = r[1];
row.appendChild(dt);
row.appendChild(dd);
dl.appendChild(row);
});
}
function countryName(code) {
var map = {
DE: "Germany",
FR: "France",
ES: "Spain",
GB: "United Kingdom",
US: "United States"
};
return map[code] || "—";
}
function isEU(code) {
return ["DE", "FR", "ES"].indexOf(code) !== -1;
}
/* ---------------------------- validation ----------------------------- */
function setError(name, msg) {
var field = form.elements[name];
var errEl = document.getElementById(name + "-err");
if (errEl) {
errEl.textContent = msg || "";
errEl.classList.toggle("show", !!msg);
}
if (field && field.setAttribute && field.classList) {
if (msg) {
field.setAttribute("aria-invalid", "true");
field.classList.remove("is-valid");
var dn = errEl ? errEl.id : null;
if (dn) {
var existing = (field.getAttribute("aria-describedby") || "")
.split(" ")
.filter(function (x) { return x && x !== dn; });
existing.push(dn);
field.setAttribute("aria-describedby", existing.join(" "));
}
} else {
field.setAttribute("aria-invalid", "false");
field.classList.add("is-valid");
}
}
}
function clearError(name) {
var field = form.elements[name];
var errEl = document.getElementById(name + "-err");
if (errEl) {
errEl.textContent = "";
errEl.classList.remove("show");
}
if (field && field.setAttribute) field.setAttribute("aria-invalid", "false");
}
var validators = {
"account-type": function () {
if (!state.answers.accountType) {
return { accountType: "Choose an account type to continue." };
}
return null;
},
profile: function () {
var e = {};
var name = (form.elements.fullName.value || "").trim();
var email = (form.elements.email.value || "").trim();
var pass = form.elements.password.value || "";
if (name.length < 2) e.fullName = "Enter your full name.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = "Enter a valid email address.";
if (pass.length < 8) e.password = "Password must be at least 8 characters.";
else if (!/\d/.test(pass)) e.password = "Include at least one number.";
return Object.keys(e).length ? e : null;
},
company: function () {
var e = {};
var cn = (form.elements.companyName.value || "").trim();
if (cn.length < 2) e.companyName = "Enter your registered company name.";
if (!form.elements.companySize.value) e.companySize = "Select your team size.";
if (!form.elements.country.value) e.country = "Select your country of registration.";
return Object.keys(e).length ? e : null;
},
tax: function () {
var e = {};
var reg = (form.elements.vatRegistered || {}).value;
if (!reg) {
e.vatRegistered = "Let us know if you're VAT-registered.";
} else if (reg === "yes") {
var vat = (form.elements.vatNumber.value || "").trim().toUpperCase();
if (!/^[A-Z]{2}[0-9A-Z]{8,12}$/.test(vat)) {
e.vatNumber = "Enter a valid VAT number (e.g. DE123456789).";
}
}
var be = (form.elements.billingEmail.value || "").trim();
if (be && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(be)) {
e.billingEmail = "Enter a valid billing email, or leave it blank.";
}
return Object.keys(e).length ? e : null;
},
review: function () {
if (!form.elements.terms.checked) {
return { terms: "Please accept the terms to finish." };
}
return null;
}
};
function collectStep(key) {
var a = state.answers;
if (key === "account-type") {
var t = form.elements.accountType;
a.accountType = t ? t.value : a.accountType;
} else if (key === "profile") {
a.fullName = form.elements.fullName.value.trim();
a.email = form.elements.email.value.trim();
a.password = form.elements.password.value;
} else if (key === "company") {
a.companyName = form.elements.companyName.value.trim();
a.companySize = form.elements.companySize.value;
a.country = form.elements.country.value;
} else if (key === "tax") {
a.vatRegistered = (form.elements.vatRegistered || {}).value || "";
a.vatNumber = form.elements.vatNumber.value.trim().toUpperCase();
a.billingEmail = form.elements.billingEmail.value.trim();
} else if (key === "review") {
a.terms = form.elements.terms.checked;
}
}
function validateStep(key) {
var fn = validators[key];
var errors = fn ? fn() : null;
if (errors) {
var first = null;
Object.keys(errors).forEach(function (name) {
setError(name, errors[name]);
if (!first) first = name;
});
var n = Object.keys(errors).length;
liveStatus.textContent =
n + (n === 1 ? " field needs" : " fields need") + " attention on this step.";
var firstEl = form.elements[first];
if (firstEl && firstEl.focus) {
var node = firstEl.length ? firstEl[0] : firstEl;
if (node && node.focus) node.focus();
}
toast(n === 1 ? "Fix the highlighted field." : "Fix " + n + " highlighted fields.", "warn");
return false;
}
liveStatus.textContent = "";
return true;
}
/* ----------------------------- navigation ---------------------------- */
function goNext() {
var key = currentKey();
collectStep(key);
if (!validateStep(key)) return;
/* The account-type answer reshapes the rest of the journey. */
if (key === "account-type") rebuildSequence(key);
if (state.index >= state.sequence.length - 1) {
finish();
return;
}
state.index += 1;
render();
liveStatus.textContent =
"Step " + (state.index + 1) + " of " + state.sequence.length + ": " + META[currentKey()].label;
}
function goBack() {
if (state.index === 0) return;
collectStep(currentKey());
state.index -= 1;
render();
liveStatus.textContent =
"Returned to step " + (state.index + 1) + ": " + META[currentKey()].label;
}
function finish() {
form.hidden = true;
backBtn.hidden = true;
var business = state.answers.accountType === "business";
doneMsg.textContent = business
? "Your business account for " +
(state.answers.companyName || "your company") +
" is ready. We've emailed a confirmation link to " +
(state.answers.email || "your inbox") +
"."
: "Welcome aboard, " +
(state.answers.fullName.split(" ")[0] || "there") +
". We've emailed a confirmation link to " +
(state.answers.email || "your inbox") +
".";
doneEl.hidden = false;
trackFill.style.width = "100%";
track.setAttribute("aria-valuenow", "100");
crumbsEl.querySelectorAll("li").forEach(function (li) {
li.className = "done";
});
liveStatus.textContent = "Account created successfully.";
toast("Account created", "ok");
restartBtn.focus();
}
function restart() {
form.reset();
steps.forEach(function (s) {
s.querySelectorAll(".field-err").forEach(function (e) {
e.textContent = "";
e.classList.remove("show");
});
s.querySelectorAll(".inp").forEach(function (i) {
i.setAttribute("aria-invalid", "false");
i.classList.remove("is-valid");
});
});
state.answers = {};
state.index = 0;
state.sequence = computeSequence();
document.getElementById("vatNumberField").hidden = true;
doneEl.hidden = true;
form.hidden = false;
render();
toast("Wizard reset");
}
/* ------------------------------- toast ------------------------------- */
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind === "warn" ? " warn" : "");
var dot = document.createElement("span");
dot.className = "dot";
el.appendChild(dot);
el.appendChild(document.createTextNode(msg));
toastHost.appendChild(el);
var t = setTimeout(function () {
el.classList.add("is-out");
el.addEventListener("animationend", function () {
if (el.parentNode) el.parentNode.removeChild(el);
});
}, 2600);
el.addEventListener("click", function () {
clearTimeout(t);
if (el.parentNode) el.parentNode.removeChild(el);
});
}
/* ------------------------------ wiring ------------------------------- */
form.addEventListener("submit", function (ev) {
ev.preventDefault();
goNext();
});
backBtn.addEventListener("click", goBack);
restartBtn.addEventListener("click", restart);
/* Account-type cards: live-update the rail count as the path changes. */
Array.prototype.forEach.call(form.elements.accountType, function (radio) {
radio.addEventListener("change", function () {
state.answers.accountType = radio.value;
clearError("accountType");
var key = currentKey();
rebuildSequence(key);
render();
liveStatus.textContent =
radio.value === "business"
? "Business selected. The wizard now has 5 steps."
: "Personal selected. The wizard now has 3 steps.";
});
});
/* VAT toggle: reveal/hide the conditional VAT number field within a step. */
Array.prototype.forEach.call(form.elements.vatRegistered, function (radio) {
radio.addEventListener("change", function () {
var field = document.getElementById("vatNumberField");
var show = radio.value === "yes";
field.hidden = !show;
clearError("vatRegistered");
if (!show) {
form.elements.vatNumber.value = "";
clearError("vatNumber");
} else {
window.requestAnimationFrame(function () {
form.elements.vatNumber.focus();
});
}
});
});
/* Clear a field's error as soon as the user starts correcting it. */
["fullName", "email", "password", "companyName", "vatNumber", "billingEmail"].forEach(
function (name) {
var el = form.elements[name];
if (el) el.addEventListener("input", function () { clearError(name); });
}
);
["companySize", "country"].forEach(function (name) {
var el = form.elements[name];
if (el) el.addEventListener("change", function () { clearError(name); });
});
form.elements.terms.addEventListener("change", function () { clearError("terms"); });
/* Smart default: prefill billing email placeholder hint based on country. */
form.elements.country.addEventListener("change", function () {
var help = document.getElementById("vatNumber-help");
if (isEU(form.elements.country.value)) {
help.textContent = "EU businesses must provide a valid VAT number.";
} else {
help.textContent = "Country prefix followed by 8–12 digits.";
}
});
/* ------------------------------- init -------------------------------- */
state.sequence = computeSequence();
state.index = 0;
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Branching Wizard — Conditional Steps</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wrap">
<header class="masthead">
<span class="kicker">Onboarding</span>
<h1 class="title">Create your Northwind account</h1>
<p class="sub">A wizard whose path adapts to your answers. Business accounts unlock extra company and tax steps that personal accounts skip.</p>
</header>
<section class="card" aria-label="Account setup wizard">
<!-- Progress / step rail -->
<div class="rail">
<div class="rail-head">
<p class="rail-count">
Step <span id="stepNow">1</span> of <span id="stepTotal">4</span>
</p>
<p class="rail-path" id="railPath">Personal path</p>
</div>
<div class="track" role="progressbar" aria-label="Wizard progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="track">
<div class="track-fill" id="trackFill"></div>
</div>
<ol class="crumbs" id="crumbs" aria-label="Steps in your current path"></ol>
</div>
<!-- Live region for validation summaries / status -->
<p class="sr-status" id="liveStatus" aria-live="polite"></p>
<form id="wizardForm" novalidate>
<div class="stage" id="stage">
<!-- STEP: account-type -->
<fieldset class="step" data-step="account-type" hidden>
<legend class="step-title">What kind of account is this?</legend>
<p class="step-sub">This decides which details we collect next.</p>
<div class="choice-grid" role="radiogroup" aria-label="Account type" aria-required="true">
<label class="choice">
<input type="radio" name="accountType" value="personal" />
<span class="choice-body">
<span class="choice-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>
</span>
<span class="choice-name">Personal</span>
<span class="choice-desc">Just for you. Three quick steps.</span>
</span>
</label>
<label class="choice">
<input type="radio" name="accountType" value="business" />
<span class="choice-body">
<span class="choice-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3 6 3v13"/><path d="M15 21V11l6 3v7"/><path d="M3 21h18"/><path d="M7 11h.01M7 14h.01M7 17h.01"/></svg>
</span>
<span class="choice-name">Business</span>
<span class="choice-desc">For a company. Adds VAT & team steps.</span>
</span>
</label>
</div>
<p class="field-err" id="accountType-err" role="alert"></p>
</fieldset>
<!-- STEP: profile -->
<fieldset class="step" data-step="profile" hidden>
<legend class="step-title">Your details</legend>
<p class="step-sub">Tell us who's running this account.</p>
<div class="grid-2">
<div class="field">
<label class="lbl" for="fullName">Full name <span class="req" aria-hidden="true">*</span></label>
<input class="inp" id="fullName" name="fullName" type="text" placeholder="Dana Okafor" autocomplete="name" aria-required="true" aria-describedby="fullName-help" />
<p class="help" id="fullName-help">As it should appear on your account.</p>
<p class="field-err" id="fullName-err" role="alert"></p>
</div>
<div class="field">
<label class="lbl" for="email">Email <span class="req" aria-hidden="true">*</span></label>
<input class="inp" id="email" name="email" type="email" placeholder="dana@example.com" autocomplete="email" aria-required="true" aria-describedby="email-help" />
<p class="help" id="email-help">We send a confirmation link here.</p>
<p class="field-err" id="email-err" role="alert"></p>
</div>
</div>
<div class="field">
<label class="lbl" for="password">Create a password <span class="req" aria-hidden="true">*</span></label>
<input class="inp" id="password" name="password" type="password" placeholder="At least 8 characters" autocomplete="new-password" aria-required="true" aria-describedby="password-help" />
<p class="help" id="password-help">Use 8+ characters with a number.</p>
<p class="field-err" id="password-err" role="alert"></p>
</div>
</fieldset>
<!-- STEP: company (business only) -->
<fieldset class="step" data-step="company" hidden>
<legend class="step-title">Company information</legend>
<p class="step-sub">Shown only because you chose a business account.</p>
<div class="field">
<label class="lbl" for="companyName">Legal company name <span class="req" aria-hidden="true">*</span></label>
<input class="inp" id="companyName" name="companyName" type="text" placeholder="Northwind Traders Ltd." autocomplete="organization" aria-required="true" aria-describedby="companyName-help" />
<p class="help" id="companyName-help">The registered legal entity name.</p>
<p class="field-err" id="companyName-err" role="alert"></p>
</div>
<div class="grid-2">
<div class="field">
<label class="lbl" for="companySize">Team size <span class="req" aria-hidden="true">*</span></label>
<select class="inp sel" id="companySize" name="companySize" aria-required="true" aria-describedby="companySize-help">
<option value="">Select a range…</option>
<option value="1–10">1–10 people</option>
<option value="11–50">11–50 people</option>
<option value="51–200">51–200 people</option>
<option value="200+">200+ people</option>
</select>
<p class="help" id="companySize-help">Helps us set seat defaults.</p>
<p class="field-err" id="companySize-err" role="alert"></p>
</div>
<div class="field">
<label class="lbl" for="country">Country of registration <span class="req" aria-hidden="true">*</span></label>
<select class="inp sel" id="country" name="country" aria-required="true" aria-describedby="country-help">
<option value="">Select a country…</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="ES">Spain</option>
<option value="GB">United Kingdom</option>
<option value="US">United States</option>
</select>
<p class="help" id="country-help">Determines the tax fields we ask for.</p>
<p class="field-err" id="country-err" role="alert"></p>
</div>
</div>
</fieldset>
<!-- STEP: tax (business only) -->
<fieldset class="step" data-step="tax" hidden>
<legend class="step-title">Tax & billing</legend>
<p class="step-sub">VAT is required for EU-registered businesses.</p>
<div class="field">
<span class="lbl" id="vatRegisteredLabel">Is your company VAT-registered? <span class="req" aria-hidden="true">*</span></span>
<div class="seg" role="radiogroup" aria-labelledby="vatRegisteredLabel" aria-required="true">
<label class="seg-opt"><input type="radio" name="vatRegistered" value="yes" /> <span>Yes</span></label>
<label class="seg-opt"><input type="radio" name="vatRegistered" value="no" /> <span>No</span></label>
</div>
<p class="field-err" id="vatRegistered-err" role="alert"></p>
</div>
<div class="field" id="vatNumberField" hidden>
<label class="lbl" for="vatNumber">VAT number <span class="req" aria-hidden="true">*</span></label>
<input class="inp" id="vatNumber" name="vatNumber" type="text" placeholder="DE123456789" autocomplete="off" aria-describedby="vatNumber-help" />
<p class="help" id="vatNumber-help">Country prefix followed by 8–12 digits.</p>
<p class="field-err" id="vatNumber-err" role="alert"></p>
</div>
<div class="field">
<label class="lbl" for="billingEmail">Billing email <span class="opt">(optional)</span></label>
<input class="inp" id="billingEmail" name="billingEmail" type="email" placeholder="finance@northwind.example" autocomplete="email" aria-describedby="billingEmail-help" />
<p class="help" id="billingEmail-help">Invoices go here instead of your account email.</p>
<p class="field-err" id="billingEmail-err" role="alert"></p>
</div>
</fieldset>
<!-- STEP: review -->
<fieldset class="step" data-step="review" hidden>
<legend class="step-title">Review & finish</legend>
<p class="step-sub">Confirm everything looks right for your <span id="reviewPathName">account</span>.</p>
<dl class="summary" id="summary"></dl>
<label class="terms" for="terms">
<input type="checkbox" id="terms" name="terms" aria-required="true" aria-describedby="terms-err" />
<span>I agree to the Northwind Terms of Service and Privacy Policy. <span class="req" aria-hidden="true">*</span></span>
</label>
<p class="field-err" id="terms-err" role="alert"></p>
</fieldset>
</div>
<!-- Nav -->
<div class="nav">
<button type="button" class="btn ghost" id="backBtn" hidden>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
Back
</button>
<span class="nav-spacer"></span>
<button type="submit" class="btn primary" id="nextBtn">
<span id="nextLabel">Continue</span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>
</button>
</div>
</form>
<!-- Success state -->
<div class="done" id="done" hidden>
<div class="done-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 class="done-title">Account created</h2>
<p class="done-msg" id="doneMsg">Welcome aboard. We've sent a confirmation link to your inbox.</p>
<button type="button" class="btn ghost" id="restartBtn">Start over</button>
</div>
</section>
<p class="footnote">Illustrative UI only — fictional company, people, and tax data.</p>
</main>
<div class="toast-host" id="toastHost" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Branching wizard (conditional steps)
A signup wizard where the route adapts to the user instead of marching everyone through the same fixed funnel. The first step asks for an account type as two large radio cards. Choosing Personal keeps the journey to three steps — account type, profile, review — while Business splices in two extra steps for company information and tax/billing before the review. A single state object holds the collected answers and a computed step sequence; the moment the account type changes, the sequence is rebuilt, so the “Step 2 of 5” counter, the gradient progress track and the breadcrumb rail all reflect the genuine length of the active branch.
Each step validates on its own terms with real vanilla-JS logic: email and
password rules on the profile step, required company name, team size and country
selectors on the business step, and a VAT step whose VAT-number field is hidden
until the user answers “Yes” to being VAT-registered — with a country-aware
helper line nudging EU businesses. Errors render inline with aria-invalid,
role="alert" messages and a polite aria-live status, and focus jumps to the
first offending control. Back always returns to the correct prior step on the
current branch, and editing the account type re-plans the remaining route
without losing earlier answers.
The final step renders a summary <dl> assembled from state — personal accounts
show only their fields, business accounts add grouped Company and Tax/billing
sections — gated behind a terms checkbox. Submitting swaps in a tailored success
panel (different copy for personal vs business), a toast helper confirms key
actions, and Start over resets state cleanly. The layout is a centered card on
the neutral product palette that collapses its two-column grids and segmented
controls to a single column at 360px.
Illustrative UI only — fictional company, people, and tax data.