Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, ChevronDown, Layers, Loader2, RefreshCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  CascadingLevel,
  CascadingLevelPhase,
  CascadingOption,
  CascadingSelectData,
  CascadingSelectPath,
  CascadingSelectValue,
} from "./cascading-select.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/cascading-select.json

Prompt

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 phaseidle / loading / ready / empty / error is 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-disabled over 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-activedescendant and swallows its own pointerdown, so opening, arrowing, hovering and committing never move DOM focus, and the keyboard owner survives every one of them.

On This Page