webdevpuneet.com

Cascading Select — Dependent Dropdowns in HTML, CSS & JS

Cascading Select · Forms · Plain HTML, CSS & JS · Live preview

CategoryForms

Copy & paste

Source Code

<form class="cs-card" id="csForm" novalidate>
  <h2 class="cs-title">Where are you based?</h2>
  <p class="cs-lead">Pick a country, then a region, then a city.</p>

  <ol class="cs-steps" aria-hidden="true">
    <li class="is-now"><b>1</b><span>Country</span></li>
    <li><b>2</b><span>Region</span></li>
    <li><b>3</b><span>City</span></li>
  </ol>

  <div class="cs-field">
    <div class="cs-row"><label class="cs-label" for="csCountry">Country</label><span class="cs-hint"></span></div>
    <div class="cs-wrap">
      <select class="cs-select" id="csCountry" name="country" required></select>
      <span class="cs-spin" aria-hidden="true"></span>
      <svg class="cs-caret" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </div>
  </div>

  <div class="cs-field">
    <div class="cs-row"><label class="cs-label" for="csState">State / Region</label><span class="cs-hint"></span></div>
    <div class="cs-wrap">
      <select class="cs-select" id="csState" name="state" required disabled></select>
      <span class="cs-spin" aria-hidden="true"></span>
      <svg class="cs-caret" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </div>
  </div>

  <div class="cs-field">
    <div class="cs-row"><label class="cs-label" for="csCity">City</label><span class="cs-hint"></span></div>
    <div class="cs-wrap">
      <select class="cs-select" id="csCity" name="city" required disabled></select>
      <span class="cs-spin" aria-hidden="true"></span>
      <svg class="cs-caret" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </div>
  </div>

  <div class="cs-summary" id="csSummary" aria-live="polite"></div>

  <div class="cs-actions">
    <button type="button" class="cs-clear" id="csClear">Start over</button>
    <button type="submit" class="cs-go" id="csGo" disabled>Continue</button>
  </div>
</form>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Native accessible selects
Real select elements keep keyboard, screen-reader and mobile-picker behaviour, with a custom caret and focus ring.
Downward reset
Changing any level clears and disables every level below it, so invalid combinations cannot be submitted.
Per-level loading state
A spinner replaces the caret while a level loads, and the select stays disabled until its options arrive.
Stale-response guard
A token per level discards responses for a choice that has since changed.
Progress steps
A three-step indicator shows the current step, ticks for finished ones and a filling connector line.
Option counts and focus flow
Each label shows how many options exist, and the newly opened select pulses and takes focus.
Result card and Continue
A card shows the chosen city, region and country; Continue unlocks only when all three are set.
FormData ready
Named, required selects return { country, state, city } from new FormData(form) on submit.

About this UI Snippet

Cascading Select — Dependent Dropdowns with Progress Steps, Loading State and Safe Reset

Screenshot of the Cascading Select snippet rendered live

Cascading (dependent) selects appear wherever data is hierarchical: country then region then city, category then subcategory, make then model. Each choice fills the next list, and the important rule is that changing a parent must clear everything below it so nobody can submit an impossible combination. This version uses native <select> elements in plain HTML, CSS and vanilla JavaScript, and adds the parts a real form needs: a three-step progress indicator, a loading state for each level, protection against slow responses arriving late, a result card and a Continue button that stays disabled until the path is complete.

Native selects, custom-looking

The controls are real <select> elements, so they stay keyboard-accessible, screen-reader friendly and use the OS picker on mobile. The default arrow is removed with appearance: none and replaced by an SVG caret that swaps for a small spinner while a level loads. Each select has a name and required attribute, so new FormData(form) returns { country, state, city } directly on submit.

One reset rule, one load function

reset(from) clears a level and everything below it, restores the "Select a country first" placeholders, disables the selects and removes their hints. load(level) fetches the options for one level from the values above it and fills them in. Every change handler does the same two things in the same order: reset the levels below, then load the next one. That order is what keeps stale choices from surviving a parent change.

Loading and stale responses

getOptions() returns a promise after a short delay to imitate an API call, so you can see the spinner and replace the timeout with fetch(). Each level has a token that increases on every reset; when a response arrives, it is dropped if its token is no longer current. Without this guard, picking India and quickly switching to Australia could fill the region list with India's regions.

Progress steps and hints

A three-step indicator above the form marks the current step, turns finished steps into ticks and fills the connecting line as you move forward. Each label shows how many options are available ("3 regions"), and the select that just opened gets a soft pulse and keyboard focus so the flow continues without reaching for the mouse.

Result card, Start over and Continue

When all three are chosen, a card shows the city large, with region and country underneath and a country-code badge. Start over clears everything and refocuses the first field. Continue is disabled until the path is complete, then confirms with a short "Saved" state.

Your data

Edit the nested DATA object and the CODES badges, or replace getOptions() with a real request that takes the parent values. Pair this with a country selector when one searchable level is enough, or a multi-select dropdown for multi-value fields.

Step by step

How to Use

  1. 1
    Paste HTML, CSS and JSCountry is ready; Region and City are disabled with "Select a country first" style placeholders, and step 1 is highlighted.
  2. 2
    Choose a countryA spinner shows briefly, then the Region list opens with a pulse and keyboard focus, and a hint says how many regions it has.
  3. 3
    Choose a region, then a cityEach choice fills the next level. Finished steps become ticks and the line between them fills in.
  4. 4
    Change a parentPick a different country and Region and City clear and disable again, so a city from the old country can never stay selected.
  5. 5
    Press Continue or Start overContinue is enabled only when all three are chosen and logs { country, state, city } from FormData. Start over clears everything.
  6. 6
    Load real dataReplace getOptions() with a fetch that takes the parent values. The reset and stale-response guard work unchanged.

Real-world uses

Common Use Cases

Checkout and signup addresses
Collect country, region and city before the street address, with the stale-response guard stopping a fast country change from showing the wrong regions.
Vehicle and product finders
Use make, model and trim, or category, subcategory and product, where each list depends on the previous choice and the option count shows how many remain.
Org and team pickers
Choose company, department and team in admin screens, with Start over to clear the whole path and refocus the first field.
Booking and travel forms
Pick region, city and venue, loading each level from an API while the spinner shows progress and the card confirms the final place.
Single-level alternative
When only the country matters, a searchable country selector is faster; this snippet is for forms where later choices depend on earlier ones.

Got questions?

Frequently Asked Questions

Replace the body of getOptions() with a fetch that uses the parent values, for example the selected country to get its regions. load() already shows the spinner, keeps the select disabled until the promise resolves and fills the options, so only that one function changes.

Every reset increases a counter for each cleared level, and load() remembers the value it started with. When a response arrives and the counter has moved on, the visitor has changed the parent in the meantime, so the response is ignored instead of filling the select with options for the wrong parent.

Set the country, wait for its regions to load, set the region, wait for the cities, then set the city. Chain the promises from getOptions() so each level is filled before its value is assigned, and call update() once at the end to refresh the steps and card.

Keep country, state and city in state, load each level in an effect that depends on the parent value, and clear the children in the parent's change handler. Ignore stale responses with a cancelled flag or AbortController. Native selects with controlled values port directly.