Skip to content

Cascading select

Country → prefecture → city, category → subcategory — Ant Design’s Cascader, done the hypermedia way: chained <select>s where each level’s change GETs the next level’s options from the server. Zero custom JavaScript: hc-select

  • htmx.

Pick a prefecture — its change GETs the city options with a real htmx request, and the same response resets the ward level out of band; pick the placeholder again to unwind the chain. The endpoint is a namespaced demo implementation of the server response contract under api/recipes/cascading-select/.

<div class="hc-field">
<label class="hc-field__label" for="prefecture">Prefecture</label>
<select class="hc-select" id="prefecture" name="prefecture"
data-hx-get="/areas/cities" data-hx-include="this"
data-hx-target="#city" data-hx-swap="outerHTML">
<option value="">Select…</option>
<option value="13">Tokyo</option>
<option value="27">Osaka</option>
</select>
</div>
<div class="hc-field">
<label class="hc-field__label" for="city">City</label>
<select class="hc-select" id="city" name="city" disabled>
<option value="">Select a prefecture first</option>
</select>
</div>

Four attributes on each parent level:

AttributeWhy
data-hx-getthe child-options endpoint
data-hx-include="this"send my own value — htmx GETs don’t include the enclosing form by default
data-hx-target="#city"the child to replace
data-hx-swap="outerHTML"the child comes back whole, so its id/name/wiring stay consistent

htmx’s default change trigger for selects is already right — no data-hx-trigger needed. Each child starts disabled with a placeholder option, so the chain reads correctly before any selection.

The server returns the child <select> re-rendered — enabled, populated, and (for a three-plus-level chain) wired with the same four attributes to load its child. Deeper levels reset in the same response as out-of-band swaps, so one response keeps the whole chain coherent:

<select class="hc-select" id="city" name="city"
data-hx-get="/areas/wards" data-hx-include="this"
data-hx-target="#ward" data-hx-swap="outerHTML">
<option value="">Select…</option>
<option value="13101">Chiyoda</option>
</select>
<select class="hc-select" id="ward" name="ward" disabled data-hx-swap-oob="true">
<option value="">Select a city first</option>
</select>

Re-selecting the placeholder is also a change: the server receives an empty value and answers with the disabled placeholder child (plus OOB resets), unwinding the chain. Unknown or stale parent values get the same answer — never an error page.

RequestResponse
GET /areas/cities?prefecture=13 (parent change)200 + the re-rendered child <select>, plus OOB resets for deeper levels
GET /areas/cities?prefecture= (placeholder re-selected)200 + the disabled placeholder child, plus OOB resets
GET /areas/cities?prefecture=99 (unknown / stale value)200 + the disabled placeholder child — never an error page

A dynamic chain needs JavaScript by definition. The blessed fallbacks: render every level server-side and let the plain form GET round trip re-render the page with the next level populated, or accept free-text input. Either way the selects serialize normally with the surrounding form — nothing about the markup traps a no-JS user.

  • Every level keeps a real <label for> — and because the child is swapped outerHTML with the same id, the association survives every swap.
  • The disabled placeholder (“Select a prefecture first”) is the affordance — screen readers announce why the level is inert.
  • Selection order is enforced by the markup (disabled until ready), not by script — there is no state a keyboard user can break.
  • Select — the underlying control.
  • Live search — when the option set is too large for a select, search instead.
  • Tree + lazy tree — hierarchical browsing, where the whole path stays visible.