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.
Live demo
Section titled “Live demo”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/.
The markup
Section titled “The markup”<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:
| Attribute | Why |
|---|---|
data-hx-get | the 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 response
Section titled “The response”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.
Server response contract
Section titled “Server response contract”| Request | Response |
|---|---|
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 |
No-JS degradation
Section titled “No-JS degradation”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.
Accessibility
Section titled “Accessibility”- Every level keeps a real
<label for>— and because the child is swappedouterHTMLwith the sameid, 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.
Related
Section titled “Related”- 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.