Form — Dependent / cascading selects
Two cascading dropdown chains built in vanilla JS over a fictional data set. A Country to State/Region to City rail repopulates and resets every descendant the moment a parent changes, keeps children disabled with clear placeholders until their parent is chosen, and shows a brief loading state as options arrive. A second Category to Subcategory pair mirrors the pattern. Real required validation focuses the first empty menu, and a confirmation panel summarizes the picks. Fully keyboard-operable with aria-live status.
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-text-size-adjust: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px 20px;
background:
radial-gradient(900px 420px at 12% -8%, var(--brand-50), transparent 60%),
radial-gradient(700px 360px at 108% 12%, var(--accent-soft), transparent 55%),
var(--bg);
}
/* ── Card ── */
.card {
position: relative;
width: min(520px, 100%);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 30px 30px 26px;
overflow: hidden;
}
.card__head {
margin-bottom: 22px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--brand-700);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.18);
padding: 5px 10px;
border-radius: 999px;
}
.badge svg {
color: var(--brand);
}
.card__title {
margin-top: 12px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
}
.card__sub {
margin-top: 6px;
font-size: 14px;
color: var(--muted);
max-width: 42ch;
}
/* ── Form ── */
.form {
display: grid;
gap: 22px;
}
.group {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 16px 16px;
background: linear-gradient(180deg, rgba(246, 247, 251, 0.55), transparent 60%);
}
.group__legend {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 8px;
font-size: 13px;
font-weight: 700;
color: var(--ink-2);
}
.group__num {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: var(--brand);
color: var(--white);
font-size: 12px;
font-weight: 700;
}
/* ── Dependency rail ── */
.rail {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
list-style: none;
margin: 4px 0 16px;
}
.rail__step {
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: var(--white);
border: 1px solid var(--line);
padding: 4px 10px;
border-radius: 999px;
transition:
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease;
}
.rail__step.is-active {
color: var(--brand-700);
border-color: rgba(91, 91, 240, 0.4);
background: var(--brand-50);
}
.rail__step.is-done {
color: var(--ok);
border-color: rgba(47, 158, 111, 0.4);
background: rgba(47, 158, 111, 0.1);
}
.rail__sep {
color: var(--line-2);
font-size: 13px;
}
/* ── Fields ── */
.field {
display: grid;
gap: 6px;
}
.field + .field {
margin-top: 14px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field__label {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
}
.req {
color: var(--danger);
font-weight: 700;
}
.control {
position: relative;
}
.select {
width: 100%;
appearance: none;
-webkit-appearance: none;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
padding: 11px 40px 11px 13px;
cursor: pointer;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease,
color 0.16s ease;
}
.select:hover:not(:disabled) {
border-color: var(--brand);
}
.select:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.22);
}
.select:invalid {
color: var(--muted);
}
.control__chev {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
display: grid;
place-items: center;
color: var(--muted);
pointer-events: none;
transition: transform 0.18s ease, color 0.16s ease;
}
.control:focus-within .control__chev {
color: var(--brand);
transform: translateY(-50%) rotate(180deg);
}
/* Disabled state */
.field.is-disabled .field__label {
color: var(--muted);
}
.select:disabled {
background: #f1f2f7;
border-color: var(--line);
color: var(--muted);
cursor: not-allowed;
}
.field.is-disabled .control__chev {
opacity: 0.5;
}
/* Loading state */
.field.is-loading .control__chev svg {
display: none;
}
.field.is-loading .control__chev::after {
content: "";
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--line-2);
border-top-color: var(--brand);
animation: spin 0.7s linear infinite;
}
/* Error state */
.field.is-error .select {
border-color: var(--danger);
background: #fdf3f1;
}
.field.is-error .select:focus-visible {
box-shadow: 0 0 0 3px rgba(212, 80, 62, 0.2);
}
.field.is-error .field__label {
color: var(--danger);
}
/* Success state */
.field.is-ok .select {
border-color: var(--ok);
}
.field.is-ok .select:focus-visible {
box-shadow: 0 0 0 3px rgba(47, 158, 111, 0.2);
}
.help {
font-size: 12.5px;
color: var(--muted);
line-height: 1.4;
min-height: 1.1em;
transition: color 0.16s ease;
}
.field.is-error .help {
color: var(--danger);
font-weight: 600;
}
.field.is-ok .help {
color: var(--ok);
}
/* ── Submit ── */
.submit {
position: relative;
width: 100%;
border: none;
font: inherit;
font-size: 15px;
font-weight: 700;
color: var(--white);
background: linear-gradient(180deg, var(--brand), var(--brand-d));
border-radius: var(--r-sm);
padding: 13px 16px;
cursor: pointer;
box-shadow: var(--sh-1);
transition:
transform 0.12s ease,
box-shadow 0.16s ease,
filter 0.16s ease;
}
.submit:hover {
filter: brightness(1.04);
box-shadow: var(--sh-2);
}
.submit:active {
transform: translateY(1px);
}
.submit:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.35);
}
.submit.is-busy {
pointer-events: none;
filter: saturate(0.85);
}
.submit.is-busy .submit__label {
opacity: 0.4;
}
.submit__spin {
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: var(--white);
opacity: 0;
}
.submit.is-busy .submit__spin {
opacity: 1;
animation: spin 0.7s linear infinite;
}
.form__foot {
text-align: center;
font-size: 12px;
color: var(--muted);
}
/* ── Success overlay ── */
.done {
text-align: center;
padding: 14px 6px 6px;
animation: rise 0.4s ease both;
}
.done__seal {
display: grid;
place-items: center;
width: 60px;
height: 60px;
margin: 0 auto 14px;
border-radius: 50%;
color: var(--white);
background: linear-gradient(180deg, var(--ok), #277f5b);
box-shadow: 0 8px 20px rgba(47, 158, 111, 0.35);
animation: pop 0.4s ease 0.1s both;
}
.done__title {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.01em;
}
.done__title:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 4px;
border-radius: 4px;
}
.done__summary {
margin: 16px auto 18px;
max-width: 360px;
display: grid;
gap: 8px;
text-align: left;
}
.done__summary > div {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
padding: 9px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.done__summary dt {
color: var(--muted);
font-weight: 600;
}
.done__summary dd {
color: var(--ink);
font-weight: 600;
text-align: right;
}
.ghost {
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--brand-700);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.2);
border-radius: var(--r-sm);
padding: 10px 18px;
cursor: pointer;
transition: background 0.16s ease;
}
.ghost:hover {
background: #e4e7ff;
}
.ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.3);
}
/* ── Toast ── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
max-width: min(420px, calc(100vw - 32px));
background: var(--ink);
color: var(--white);
font-size: 13.5px;
font-weight: 500;
padding: 11px 16px;
border-radius: var(--r-sm);
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.22s ease;
z-index: 20;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ── Animations ── */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pop {
0% {
transform: scale(0.6);
opacity: 0;
}
60% {
transform: scale(1.08);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Responsive ── */
@media (max-width: 520px) {
.card {
padding: 24px 18px 20px;
border-radius: var(--r-md);
}
.card__title {
font-size: 21px;
}
.row {
grid-template-columns: 1fr;
}
.rail {
gap: 6px;
}
.rail__step {
font-size: 11px;
padding: 3px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ──────────────────────────────────────────────────────────
* Fictional data sets
* ────────────────────────────────────────────────────────── */
// Country → Region → City
const GEO = {
AT: {
label: "Atlantis",
regions: {
"AT-CO": { label: "Coral Coast", cities: ["Pearlhaven", "Tidemoor", "Saltspire"] },
"AT-DP": { label: "Deepvale", cities: ["Abyssgate", "Lumenreef"] },
"AT-RD": { label: "Reefdale", cities: ["Anemone Bay", "Kelpford", "Nautilus Point"] },
},
},
NV: {
label: "Novaria",
regions: {
"NV-AU": { label: "Aurora Province", cities: ["Polaris City", "Frostmere", "Glimmerhold"] },
"NV-SK": { label: "Skylund", cities: ["Cloudreach", "Zephyr Falls"] },
},
},
VR: {
label: "Verdantia",
regions: {
"VR-MO": { label: "Mossgrove", cities: ["Ferndale", "Willowbrook", "Thornhollow"] },
"VR-SN": { label: "Sunmeadow", cities: ["Goldhaven", "Amberfield"] },
"VR-CA": { label: "Canopy Heights", cities: ["Emberleaf", "Vinewatch", "Brackenrise"] },
},
},
ZE: {
label: "Zephyria",
regions: {
"ZE-DU": { label: "Dunewind", cities: ["Mirage Wells", "Sandrest"] },
"ZE-OA": { label: "Oasis Belt", cities: ["Palm Crossing", "Springgate", "Verdant Hollow"] },
},
},
};
// Category → Subcategory
const CAT = {
apparel: { label: "Apparel", subs: ["Outerwear", "Footwear", "Accessories", "Activewear"] },
home: { label: "Home & Living", subs: ["Lighting", "Cookware", "Bedding", "Decor"] },
tech: { label: "Electronics", subs: ["Audio", "Wearables", "Cameras"] },
outdoor: { label: "Outdoor", subs: ["Camping", "Cycling", "Climbing", "Watersports"] },
};
/* ──────────────────────────────────────────────────────────
* Helpers
* ────────────────────────────────────────────────────────── */
const $ = (sel, root) => (root || document).querySelector(sel);
const els = {
form: $("#ship"),
country: $("#country"),
region: $("#region"),
city: $("#city"),
category: $("#category"),
subcategory: $("#subcategory"),
submit: $("#submit"),
done: $("#done"),
doneSummary: $("#done-summary"),
doneTitle: $(".done__title", $("#done")),
reset: $("#reset"),
toast: $("#toast"),
};
function fieldOf(select) {
return select.closest(".field");
}
let toastTimer = null;
function toast(msg) {
els.toast.textContent = msg;
els.toast.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("is-show"), 2600);
}
// Build <option> elements from a list of {value,label} or strings.
function fillOptions(select, items, placeholder) {
select.innerHTML = "";
const ph = document.createElement("option");
ph.value = "";
ph.disabled = true;
ph.selected = true;
ph.textContent = placeholder;
select.appendChild(ph);
items.forEach((it) => {
const opt = document.createElement("option");
if (typeof it === "string") {
opt.value = it;
opt.textContent = it;
} else {
opt.value = it.value;
opt.textContent = it.label;
}
select.appendChild(opt);
});
}
// Lock a select back to a disabled placeholder state.
function lockSelect(select, placeholder) {
select.disabled = true;
select.innerHTML = "";
const ph = document.createElement("option");
ph.value = "";
ph.disabled = true;
ph.selected = true;
ph.textContent = placeholder;
select.appendChild(ph);
setState(select, "neutral");
}
// Visual state per field: neutral | ok | error | disabled
function setState(select, state, msg) {
const field = fieldOf(select);
const help = $(".help", field);
field.classList.remove("is-ok", "is-error", "is-disabled");
select.removeAttribute("aria-invalid");
if (state === "ok") {
field.classList.add("is-ok");
} else if (state === "error") {
field.classList.add("is-error");
select.setAttribute("aria-invalid", "true");
} else if (state === "disabled") {
field.classList.add("is-disabled");
}
if (msg !== undefined && help) {
help.textContent = msg;
}
}
// Sync the disabled visual class with the actual disabled prop.
function syncDisabledClass(select) {
fieldOf(select).classList.toggle("is-disabled", select.disabled);
}
// Simulate a tiny async populate so the dependency reads as real.
function populateAsync(select, build) {
const field = fieldOf(select);
field.classList.add("is-loading");
select.disabled = true;
syncDisabledClass(select);
setTimeout(() => {
build();
field.classList.remove("is-loading");
select.disabled = false;
syncDisabledClass(select);
}, 260);
}
/* ──────────────────────────────────────────────────────────
* Dependency rail (Country → Region → City)
* ────────────────────────────────────────────────────────── */
function updateRail() {
const map = {
country: !!els.country.value,
region: !!els.region.value,
city: !!els.city.value,
};
document.querySelectorAll(".rail__step").forEach((step) => {
const key = step.getAttribute("data-rail");
step.classList.remove("is-active", "is-done");
if (map[key]) {
step.classList.add("is-done");
} else {
// The first unfilled step is the active one.
const order = ["country", "region", "city"];
const idx = order.indexOf(key);
const prevDone = order.slice(0, idx).every((k) => map[k]);
if (prevDone) step.classList.add("is-active");
}
});
}
/* ──────────────────────────────────────────────────────────
* Wiring: location cascade
* ────────────────────────────────────────────────────────── */
// Seed countries.
fillOptions(
els.country,
Object.keys(GEO).map((code) => ({ value: code, label: GEO[code].label })),
"Choose a country…"
);
els.country.addEventListener("change", () => {
const code = els.country.value;
setState(els.country, code ? "ok" : "neutral", "Start here — the rest depends on it.");
// Reset descendants whenever the parent changes.
lockSelect(els.region, "Pick a region…");
lockSelect(els.city, "Pick a region first");
syncDisabledClass(els.city);
setState(els.region, "disabled", "Loading regions…");
setState(els.city, "disabled", "Unlocks once a region is chosen.");
updateRail();
if (!code) return;
const regions = GEO[code].regions;
populateAsync(els.region, () => {
fillOptions(
els.region,
Object.keys(regions).map((rc) => ({ value: rc, label: regions[rc].label })),
"Choose a region…"
);
setState(
els.region,
"neutral",
Object.keys(regions).length + " regions in " + GEO[code].label + "."
);
els.region.focus();
});
toast("Loaded regions for " + GEO[code].label + ".");
});
els.region.addEventListener("change", () => {
const cc = els.country.value;
const rc = els.region.value;
setState(els.region, rc ? "ok" : "neutral");
lockSelect(els.city, "Choose a city…");
setState(els.city, "disabled", "Loading cities…");
updateRail();
if (!cc || !rc) return;
const cities = GEO[cc].regions[rc].cities;
populateAsync(els.city, () => {
fillOptions(els.city, cities, "Choose a city…");
setState(els.city, "neutral", cities.length + " cities available.");
els.city.focus();
});
});
els.city.addEventListener("change", () => {
setState(els.city, els.city.value ? "ok" : "neutral");
updateRail();
});
/* ──────────────────────────────────────────────────────────
* Wiring: category cascade
* ────────────────────────────────────────────────────────── */
fillOptions(
els.category,
Object.keys(CAT).map((key) => ({ value: key, label: CAT[key].label })),
"Choose a category…"
);
els.category.addEventListener("change", () => {
const key = els.category.value;
setState(els.category, key ? "ok" : "neutral", "Sets which subcategories appear.");
lockSelect(els.subcategory, "Pick a category first");
setState(els.subcategory, "disabled", "Loading subcategories…");
if (!key) return;
const subs = CAT[key].subs;
populateAsync(els.subcategory, () => {
fillOptions(els.subcategory, subs, "Choose a subcategory…");
setState(els.subcategory, "neutral", subs.length + " subcategories in " + CAT[key].label + ".");
els.subcategory.focus();
});
});
els.subcategory.addEventListener("change", () => {
setState(els.subcategory, els.subcategory.value ? "ok" : "neutral");
});
/* ──────────────────────────────────────────────────────────
* Submit / validation
* ────────────────────────────────────────────────────────── */
const REQUIRED = [
{ el: () => els.country, msg: "Pick a country to continue." },
{ el: () => els.region, msg: "Pick a region." },
{ el: () => els.city, msg: "Pick a city." },
{ el: () => els.category, msg: "Pick a category." },
{ el: () => els.subcategory, msg: "Pick a subcategory." },
];
function validate() {
let firstInvalid = null;
REQUIRED.forEach((r) => {
const sel = r.el();
if (!sel.value) {
setState(sel, "error", r.msg);
if (!firstInvalid) firstInvalid = sel;
} else {
setState(sel, "ok");
}
});
return firstInvalid;
}
els.form.addEventListener("submit", (e) => {
e.preventDefault();
const firstInvalid = validate();
if (firstInvalid) {
toast("Fill every menu before confirming.");
// Focus the field, enabling it first if a parent gap left it locked.
if (firstInvalid.disabled) {
const gap = REQUIRED.find((r) => !r.el().disabled && !r.el().value);
if (gap) gap.el().focus();
} else {
firstInvalid.focus();
}
return;
}
els.submit.classList.add("is-busy");
setTimeout(() => {
els.submit.classList.remove("is-busy");
showDone();
}, 700);
});
function showDone() {
const cc = els.country.value;
const rc = els.region.value;
const rows = [
["Country", GEO[cc].label],
["Region", GEO[cc].regions[rc].label],
["City", els.city.value],
["Category", CAT[els.category.value].label],
["Subcategory", els.subcategory.value],
];
els.doneSummary.innerHTML = "";
rows.forEach(([dt, dd]) => {
const wrap = document.createElement("div");
const dtEl = document.createElement("dt");
dtEl.textContent = dt;
const ddEl = document.createElement("dd");
ddEl.textContent = dd;
wrap.append(dtEl, ddEl);
els.doneSummary.appendChild(wrap);
});
els.form.hidden = true;
els.done.hidden = false;
els.doneTitle.focus();
toast("Selection confirmed.");
}
els.reset.addEventListener("click", () => {
els.form.reset();
fillOptions(
els.country,
Object.keys(GEO).map((code) => ({ value: code, label: GEO[code].label })),
"Choose a country…"
);
fillOptions(
els.category,
Object.keys(CAT).map((key) => ({ value: key, label: CAT[key].label })),
"Choose a category…"
);
lockSelect(els.region, "Pick a country first");
lockSelect(els.city, "Pick a region first");
lockSelect(els.subcategory, "Pick a category first");
setState(els.country, "neutral", "Start here — the rest depends on it.");
setState(els.region, "disabled", "Unlocks once a country is chosen.");
setState(els.city, "disabled", "Unlocks once a region is chosen.");
setState(els.category, "neutral", "Sets which subcategories appear.");
setState(els.subcategory, "disabled", "Unlocks once a category is chosen.");
updateRail();
els.done.hidden = true;
els.form.hidden = false;
els.country.focus();
});
/* ── Initial visual state ── */
syncDisabledClass(els.region);
syncDisabledClass(els.city);
syncDisabledClass(els.subcategory);
setState(els.region, "disabled");
setState(els.city, "disabled");
setState(els.subcategory, "disabled");
updateRail();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dependent / cascading selects</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<section class="card" aria-labelledby="form-title">
<header class="card__head">
<span class="badge">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="currentColor"
d="M3 6h18v2H3zm3 5h12v2H6zm3 5h6v2H9z"
/>
</svg>
Cascading inputs
</span>
<h1 id="form-title" class="card__title">Where should we ship it?</h1>
<p class="card__sub">
Each menu unlocks the next. Pick a country and the region list
fills in; pick a region and the cities follow.
</p>
</header>
<form id="ship" class="form" novalidate>
<!-- ── Location group ── -->
<fieldset class="group">
<legend class="group__legend">
<span class="group__num" aria-hidden="true">1</span>
Delivery location
</legend>
<!-- Dependency rail -->
<ol class="rail" aria-hidden="true">
<li class="rail__step is-active" data-rail="country">Country</li>
<li class="rail__sep">→</li>
<li class="rail__step" data-rail="region">Region</li>
<li class="rail__sep">→</li>
<li class="rail__step" data-rail="city">City</li>
</ol>
<!-- Country -->
<div class="field" data-field="country">
<label class="field__label" for="country">
Country <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<select
id="country"
name="country"
class="select"
required
aria-required="true"
aria-describedby="country-help"
data-step="0"
>
<option value="" disabled selected>Choose a country…</option>
</select>
<span class="control__chev" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="m7 10 5 5 5-5z" />
</svg>
</span>
</div>
<p id="country-help" class="help">Start here — the rest depends on it.</p>
</div>
<!-- Region -->
<div class="field" data-field="region">
<label class="field__label" for="region">
State / Region <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<select
id="region"
name="region"
class="select"
required
aria-required="true"
aria-describedby="region-help"
data-step="1"
disabled
>
<option value="" disabled selected>Pick a country first</option>
</select>
<span class="control__chev" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="m7 10 5 5 5-5z" />
</svg>
</span>
</div>
<p id="region-help" class="help">Unlocks once a country is chosen.</p>
</div>
<!-- City -->
<div class="field" data-field="city">
<label class="field__label" for="city">
City <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<select
id="city"
name="city"
class="select"
required
aria-required="true"
aria-describedby="city-help"
data-step="2"
disabled
>
<option value="" disabled selected>Pick a region first</option>
</select>
<span class="control__chev" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="m7 10 5 5 5-5z" />
</svg>
</span>
</div>
<p id="city-help" class="help">Unlocks once a region is chosen.</p>
</div>
</fieldset>
<!-- ── Category group ── -->
<fieldset class="group">
<legend class="group__legend">
<span class="group__num" aria-hidden="true">2</span>
Product category
</legend>
<div class="row">
<!-- Category -->
<div class="field" data-field="category">
<label class="field__label" for="category">
Category <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<select
id="category"
name="category"
class="select"
required
aria-required="true"
aria-describedby="category-help"
data-step="0"
>
<option value="" disabled selected>Choose a category…</option>
</select>
<span class="control__chev" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="m7 10 5 5 5-5z" />
</svg>
</span>
</div>
<p id="category-help" class="help">Sets which subcategories appear.</p>
</div>
<!-- Subcategory -->
<div class="field" data-field="subcategory">
<label class="field__label" for="subcategory">
Subcategory <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<select
id="subcategory"
name="subcategory"
class="select"
required
aria-required="true"
aria-describedby="subcategory-help"
data-step="1"
disabled
>
<option value="" disabled selected>Pick a category first</option>
</select>
<span class="control__chev" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="m7 10 5 5 5-5z" />
</svg>
</span>
</div>
<p id="subcategory-help" class="help">Unlocks once a category is chosen.</p>
</div>
</div>
</fieldset>
<button id="submit" type="submit" class="submit">
<span class="submit__label">Confirm selection</span>
<span class="submit__spin" aria-hidden="true"></span>
</button>
<p class="form__foot">
Sample data is fictional. Nothing is sent anywhere.
</p>
</form>
<!-- Success overlay -->
<div id="done" class="done" hidden>
<div class="done__seal" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30">
<path
fill="currentColor"
d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
</div>
<h2 class="done__title" tabindex="-1">Selection confirmed</h2>
<dl class="done__summary" id="done-summary"></dl>
<button type="button" class="ghost" id="reset">Start over</button>
</div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Dependent / cascading selects
A shipping form with two dependency chains. The first is a classic Country to State/Region to City cascade over a fictional world (Atlantis, Novaria, Verdantia, Zephyria). Choosing a country populates the region menu and unlocks it; choosing a region populates and unlocks the cities. A small breadcrumb rail above the fields shows exactly where the dependency stands, marking each step active or done. A second Category to Subcategory pair sits below, following the same rules on a separate data set.
Every child menu starts disabled with a placeholder like “Pick a country first,” so the dependency is legible before you touch anything. Changing any parent resets and repopulates its descendants from scratch, clears their values, and re-locks the ones further down the chain. New options arrive after a brief simulated load with a spinner in the field, and focus moves to the newly unlocked menu so keyboard users stay in flow.
Submitting runs a real required-field pass: empty menus get a red border, aria-invalid, and a specific helper message, and focus jumps to the first menu that still needs an answer. A valid submit shows a short loading state and then a confirmation panel that lists the resolved country, region, city, category, and subcategory in plain language, with a “Start over” action that fully resets both chains. Status changes are announced through an aria-live toast.