Cascading Select
Dependent selects — country then region then city: each rung loads from the one above, changing an upper rung clears the lower ones out loud, and an answer for a rung the reader already left is discarded.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cascading-select.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CascadingSelect" component: a column of
dependent select-only comboboxes where each rung is unlocked, scoped and
invalidated by the one above it. lucide-react for icons, zod for the contract,
no popover library and no data-fetching library.
Contract
- A zod file is the single source of truth; the props are z.infer of it plus the
component-only levers below.
- CascadingOption: { value, label, parent?, hint?, disabled?, reason? }.
`parent` is what makes a whole tree expressible as FLAT arrays: each level
carries a pool, each option names the upstream value it belongs to, and the
component filters the pool by the committed parent — no recursion, no
`children`, no z.lazy. An option with NO parent belongs to every branch, which
is how "Other" / "Not listed" stays available everywhere without being copied
once per parent.
- CascadingLevel: { id, label, options, value, placeholder?, emptyMessage? }.
`value` is nullable-but-required: "not chosen yet" is a fact the data layer has
to state, and an absent key would be indistinguishable from a typo in the id.
- Envelope: { status: "loading" | "empty" | "error" | "ready", levels[],
errorMessage? } — did the level DEFINITIONS arrive. Independent of any one
rung's own state: a city list still in flight is a healthy `ready` cascade with
one busy rung in it.
- onValueChange hands back three views of one selection, because the three
consumers want different things: byLevel (feed it straight back as `value`),
path (the committed options, already resolved), complete (computed from the
values, so an option object whose pool has not loaded cannot make a full
selection report as incomplete).
- loadOptions?: (request) => Promise<CascadingOption[]> where request is
{ levelId, index, parentValue, parent, path, signal }. Called ONLY for levels
that have a committed parent AND no options of their own — a static tree never
touches it, and the root level never reaches it (its pool is the contract's
job, which is also what keeps the first paint server-renderable).
- Component levers: value? (controlled path keyed by level id; omit it and
levels[].value seeds the internal one), onRetry (envelope-level), disabled,
layout "stack" | "row", clearable, advanceFocus, name (one hidden input per
rung, `<name>.<levelId>`), emptyMessage. forwardRef<HTMLDivElement>; the rest
of the native div props spread onto the root.
Behavior
- Per-level phase is DERIVED at render time, never stored: idle (nothing
committed upstream) / loading / ready / empty / error. Two sources for "is this
rung busy" drift the moment a response is thrown away.
- Every cached pool and every in-flight request remembers the parent value it
belongs to. A cache whose parent no longer matches is not displayed and not
trusted; it simply means "a load is needed". That one comparison is what makes
an externally changed `value` prop as safe as a click.
- Stale answers are discarded by IDENTITY: the in-flight record for a level is
kept in a ref Map, written synchronously before the promise exists, and the
resolve handler bails unless the map still points at its own record. Aborting
is a courtesy on top (the signal is passed through), but a loader that ignores
its signal, or a fetch already past the network, is still discarded correctly.
Never sequence numbers spread across state — state is a render behind.
- A commit on level i: sets i, clears every level below it, aborts and forgets
their in-flight requests, drops their cached pools, and names them in a polite
live region — "Country changed — Region and City were cleared." Re-picking the
value that is already there clears nothing.
- Focus follows the cascade: committing moves focus to the next rung's trigger
(advanceFocus). That trigger always exists — deeper rungs are rendered inert,
not omitted — so this is a plain focus move, never a focus into nothing.
- Keyboard, per trigger: ArrowDown / ArrowUp / Enter / Space open the list with
the highlight on the current choice (falling back to the first / last
selectable row); once open, arrows move the highlight and stop at the ends like
a native select, Home / End jump to the first / last selectable row, Enter and
Space commit, Escape closes and keeps the value (stopPropagation, so a
surrounding dialog does not close on the same key), Tab closes without
preventDefault so the browser still moves focus. Enter and Space are always
preventDefault-ed: Space must not scroll the page and Enter must not submit the
surrounding form.
- Typeahead: printable characters build a buffer that expires 500ms after the
last keystroke; repeating one character cycles through the rows starting with
it. Typing on a CLOSED trigger opens the list and highlights the match instead
of committing — a stray keystroke must never clear two rungs of a form.
- ARIA: each trigger is a <button role="combobox"> with aria-haspopup="listbox",
aria-expanded, aria-controls (only while open — a dangling id is an ARIA
error), aria-activedescendant pointing at the highlighted row, and
aria-labelledby naming the label element AND the span holding the current
value, because with role="combobox" the content is the value. Focus never
leaves the trigger: rows are <li role="option"> with aria-selected, and the
list's own pointerdown is preventDefault-ed so a press on a row — or on the
list's padding — cannot blur the trigger to the document body. The click still
fires.
- Never the native disabled attribute. Every trigger goes inert underneath the
user (the rung below the one just changed, the rung whose request is in
flight), and the browser blurs a node the instant it becomes disabled: use
aria-disabled plus an early return in every handler. Blocked options stay in
the list, aria-disabled, with their reason printed under the label and a
fallback sentence when the API forgot to send one — an option that silently
vanishes cannot be told apart from one that never existed.
- Anything that unmounts under the user hands focus on first: the per-rung
"Retry" (which is replaced by the loading row it triggers) and "Clear all"
(which disappears the moment it succeeds) both focus a trigger BEFORE acting.
Retry is one-shot through the same ref map, so a double click cannot start two
requests.
- Per-rung empty is a sentence, not a blank list: "No city available in
Okinawa." (level emptyMessage, else the level label plus the parent's label) is
a different answer from "the request failed", which gets role="alert", the
loader's own message and its own Retry.
- A committed value the pool does not contain is printed as itself rather than
read as unset — a zone retired last quarter is still on the record being
edited. Hidden form inputs are written from the RESOLVED values, so a value
belonging to a branch that no longer exists is never posted.
- Cleanup: on unmount abort every in-flight request AND clear the ref map (the
empty map is also what makes a late answer fail its identity check instead of
calling setState on a dead tree), clear the typeahead timer, and remove the
outside-pointerdown listener, which is only subscribed while a list is open.
- Announcements: one always-mounted polite region carries the visible clearing
sentence, one visually hidden role="status" carries loader progress ("Loading
region", "12 cities available", "Could not load city"). A live region that
arrives together with its text is not announced at all.
Rendering & styling
- Semantic tokens only: trigger border + bg-background, bg-muted when inert,
border-ring while open, border-destructive + text-destructive when the rung
failed, popover bg-popover / text-popover-foreground with border and shadow,
highlighted row bg-accent / text-accent-foreground, muted-foreground for
labels, hints, placeholders and blocked rows, border-dashed for the empty
envelope.
- cn() merges every className; focus-visible:ring-2 ring-ring on every trigger
and button. The trigger truncates a long value; list rows wrap instead, so a
long label is never silently cut.
- Motion is decoration: a 120ms list fade-in behind motion-reduce, the chevron
rotation and colour transitions behind motion-reduce:transition-none, the
spinner behind motion-reduce:animate-none — with motion off the word "Loading"
is what carries the state.
- Keyframes ship as a React 19 hoisted <style> with a precedence, so there is no
Tailwind config to edit.
Customization levers
- Depth is data: two rungs or five, same component. The tree shape lives in the
contract, not in the JSX.
- Static vs async is a per-level decision, not a mode: a level that arrives with
a pool is never fetched, a level that arrives empty is. Mix them freely
(country static and server-rendered, city fetched).
- layout="row" wraps the rungs into one line (each basis-52, wrapping instead of
shrinking); "stack" gives one per row. Density lives in the trigger's h-10 /
px-3 and the row's px-2 py-1.5 — nothing is measured in JS.
- advanceFocus={false} if your form does its own focus choreography; clearable
false to drop "Clear all"; add a per-rung clear by committing null for that
level and running the same downstream-clearing routine.
- Wording is all in props: level `placeholder`, level `emptyMessage`, the
envelope `emptyMessage` / `errorMessage`, and each option's `hint` / `reason`.
- Tokens: the highlighted row is the one strong colour — move it to bg-primary /
text-primary-foreground for a heavier look, or tint the whole trigger with
var(--chart-1) when the cascade is filtering a chart.
- To escape a clipping ancestor, mount the same list in a portal: nothing in the
logic assumes it is a sibling of the trigger.Concepts
- Parent-scoped options — a level is not "the next dropdown", it is a pool plus the question that scopes it; every option names the upstream value it belongs to, so a three-level tree is three flat arrays and a catch-all with no parent belongs to every branch.
- Derived phase —
idle / loading / ready / empty / erroris computed each render from the pool, the parent's value and the in-flight request, never stored; storing it would give "is this rung busy" a second source that drifts the first time an answer is thrown away. - Discard by identity — the live request for a level is a record in a ref map, written before the promise exists; an answer is only accepted while the map still points at its own record, which is why a slow response for a region the reader already left cannot overwrite a fast one, even from a loader that ignores its abort signal.
- Announced invalidation — clearing the rungs below a change is not a silent side effect: they are named in a polite live region and in a visible line, because a form that quietly loses two fields is how people submit the wrong city.
- Inert, not absent — deeper rungs are rendered from the first paint, focusable, and say which rung to fill first; combined with
aria-disabledover the native attribute, nothing the user is standing on can ever be ripped out from under them. - Focus stays on the trigger — the list carries its highlight through
aria-activedescendantand swallows its own pointerdown, so opening, arrowing, hovering and committing never move DOM focus, and the keyboard owner survives every one of them.
Sort Builder
A multi-key sort composer — ordered field plus direction rules, drag or keyboard reprioritising, direction wording that follows the field type, and a live plain-language summary of the whole chain.
Business Hours
A weekly opening-hours editor — per-day open/closed switches, split shifts, blocks that cross midnight, overlap detection that follows the spill into the next day, and a plain-language summary.