ComponentsForm
Cascade select
Lets people choose one option from a tree, one level at a time, in menus that open beside each other.
Import
import { CascadeSelect } from "@booleanpress/ui/cascade-select"Usage
Give the field a visible name, a Label whose htmlFor is its id, and the tree as options. An option with children is a group that opens the next level; an option without is a leaf, and choosing a leaf sets the value and closes the menu.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const OFFICES: CascadeSelectOption[] = [
{
value: "us",
label: "United States",
children: [
{ value: "us-ca", label: "California", children: [{ value: "los-angeles", label: "Los Angeles" }] },
],
},
]
export function OfficeField() {
return (
<>
<Label htmlFor="office">Office</Label>
<CascadeSelect id="office" options={OFFICES} placeholder="Select a city" />
</>
)
}It is uncontrolled with defaultValue, or controlled with value and onValueChange, which also receives the options from the top level down to the leaf. The value is a leaf's value, so every value in the tree must be unique; "" means nothing is chosen. The field shows the leaf's label, or the whole path with showPath (levels joined by separator, " / " by default). Opening the menu again opens every level on the chosen path and puts focus on the chosen option.
For a tree too large to send at once, mark a group hasChildren instead of giving it children, and pass loadOptions: it runs the first time that group opens, the level says "Loading" until it resolves, and its result is kept. If it rejects, the level says so (the provider's loadFailed, "Could not load the options") and opening the group again tries once more. loading does the same for the top level. size is sm, default or lg and variant="filled" fills the field grey, both defaulting to the provider's controlSize and fieldVariant; fluid fills the container and clearable adds a clear button while a value is chosen. With name, a hidden input carries the value in a form; a disabled field is not submitted, and a form reset puts an uncontrolled field back to its defaultValue, as with a native select. For a flat list use a select.
Examples
Basic
Country, then state, then city: each group opens the next level beside it.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const OFFICES: CascadeSelectOption[] = [
{
value: "au",
label: "Australia",
children: [
{ value: "au-nsw", label: "New South Wales", children: [{ value: "sydney", label: "Sydney" }, { value: "newcastle", label: "Newcastle" }] },
{ value: "au-qld", label: "Queensland", children: [{ value: "brisbane", label: "Brisbane" }, { value: "townsville", label: "Townsville" }] },
],
},
{
value: "us",
label: "United States",
children: [
{ value: "us-ca", label: "California", children: [{ value: "los-angeles", label: "Los Angeles" }, { value: "san-francisco", label: "San Francisco" }] },
{ value: "us-ny", label: "New York", children: [{ value: "new-york-city", label: "New York City" }, { value: "buffalo", label: "Buffalo" }] },
],
},
{
value: "ca",
label: "Canada",
children: [
{ value: "ca-on", label: "Ontario", children: [{ value: "toronto", label: "Toronto" }, { value: "ottawa", label: "Ottawa" }] },
{ value: "ca-qc", label: "Quebec", children: [{ value: "montreal", label: "Montreal" }, { value: "quebec-city", label: "Quebec City" }] },
],
},
]
export default function CascadeSelectBasic() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="office-city">Office</Label>
<CascadeSelect id="office-city" options={OFFICES} placeholder="Select a city" className="w-full" />
</div>
)
}Show the path
showPath writes every level of the chosen option in the field, not only the city.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const REGIONS: CascadeSelectOption[] = [
{
value: "eu",
label: "Europe",
children: [
{ value: "eu-de", label: "Germany", children: [{ value: "frankfurt", label: "Frankfurt" }, { value: "berlin", label: "Berlin" }] },
{ value: "eu-ie", label: "Ireland", children: [{ value: "dublin", label: "Dublin" }] },
],
},
{
value: "na",
label: "North America",
children: [
{ value: "na-us", label: "United States", children: [{ value: "virginia", label: "Virginia" }, { value: "oregon", label: "Oregon" }] },
{ value: "na-ca", label: "Canada", children: [{ value: "montreal", label: "Montreal" }] },
],
},
]
export default function CascadeSelectShowPath() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="data-region">Data region</Label>
<CascadeSelect id="data-region" options={REGIONS} defaultValue="frankfurt" showPath className="w-full" />
</div>
)
}Groups with icons
An option's icon shows before its label, in groups and leaves alike.
import {
KeyRoundIcon,
LogInIcon,
MailCheckIcon,
MailIcon,
MailOpenIcon,
MailXIcon,
SendIcon,
TriangleAlertIcon,
UserIcon,
WebhookIcon,
} from "lucide-react"
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const EVENTS: CascadeSelectOption[] = [
{
value: "email",
label: "Email",
icon: <MailIcon />,
children: [
{ value: "email-delivered", label: "Delivered", icon: <MailCheckIcon /> },
{ value: "email-opened", label: "Opened", icon: <MailOpenIcon /> },
{ value: "email-bounced", label: "Bounced", icon: <MailXIcon /> },
],
},
{
value: "webhook",
label: "Webhook",
icon: <WebhookIcon />,
children: [
{ value: "webhook-sent", label: "Sent", icon: <SendIcon /> },
{ value: "webhook-failed", label: "Failed", icon: <TriangleAlertIcon /> },
],
},
{
value: "account",
label: "Account",
icon: <UserIcon />,
children: [
{ value: "account-sign-in", label: "Signed in", icon: <LogInIcon /> },
{ value: "account-password", label: "Password changed", icon: <KeyRoundIcon /> },
],
},
]
export default function CascadeSelectGroupsWithIcons() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="alert-event">Alert on</Label>
<CascadeSelect id="alert-event" options={EVENTS} placeholder="Choose an event" className="w-full" />
</div>
)
}Clear
clearable shows a clear button while a value is chosen; it empties the field and keeps focus on it.
import { useState } from "react"
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const TOPICS: CascadeSelectOption[] = [
{
value: "billing",
label: "Billing",
children: [
{ value: "refund", label: "Refund" },
{ value: "invoice", label: "Invoice" },
],
},
{
value: "delivery",
label: "Delivery",
children: [
{ value: "bounce", label: "Bounces" },
{ value: "spam", label: "Marked as spam" },
],
},
]
export default function CascadeSelectClear() {
const [topic, setTopic] = useState("bounce")
const [topicLabel, setTopicLabel] = useState("Bounces")
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="ticket-topic">Ticket topic</Label>
<CascadeSelect
id="ticket-topic"
options={TOPICS}
value={topic}
onValueChange={(value, path) => {
setTopic(value)
setTopicLabel(path.at(-1)?.label ?? "")
}}
placeholder="Any topic"
clearable
className="w-full"
/>
<p className="text-xs text-muted-foreground">{topic ? `Showing ${topicLabel.toLowerCase()} tickets.` : "Showing every ticket."}</p>
</div>
)
}Sizes
sm is 28 px high, default 35 px and lg 42 px.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
const TEAMS: CascadeSelectOption[] = [
{
value: "support",
label: "Support",
children: [
{ value: "support-tier-1", label: "Tier 1" },
{ value: "support-tier-2", label: "Tier 2" },
],
},
{
value: "billing",
label: "Billing",
children: [{ value: "billing-refunds", label: "Refunds" }],
},
]
const SIZES = [
{ size: "sm", label: "Small" },
{ size: "default", label: "Default" },
{ size: "lg", label: "Large" },
] as const
export default function CascadeSelectSizes() {
return (
<div className="flex w-full max-w-56 flex-col gap-4">
{SIZES.map(({ size, label }) => (
<CascadeSelect key={size} size={size} options={TEAMS} placeholder={label} aria-label={`Team, ${label.toLowerCase()} size`} className="w-full" />
))}
</div>
)
}Filled
variant="filled" fills the field with the grey --field-filled, on hover and focus too.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const TEAMS: CascadeSelectOption[] = [
{
value: "support",
label: "Support",
children: [
{ value: "support-tier-1", label: "Tier 1" },
{ value: "support-tier-2", label: "Tier 2" },
],
},
{
value: "billing",
label: "Billing",
children: [{ value: "billing-refunds", label: "Refunds" }],
},
]
export default function CascadeSelectFilled() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="assignee-team">Assign to</Label>
<CascadeSelect id="assignee-team" variant="filled" options={TEAMS} placeholder="Choose a team" className="w-full" />
</div>
)
}Fluid
fluid makes the field as wide as its container.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const TEAMS: CascadeSelectOption[] = [
{
value: "support",
label: "Support",
children: [
{ value: "support-tier-1", label: "Tier 1" },
{ value: "support-tier-2", label: "Tier 2" },
],
},
{
value: "billing",
label: "Billing",
children: [{ value: "billing-refunds", label: "Refunds" }],
},
]
export default function CascadeSelectFluid() {
return (
<div className="flex w-full flex-col gap-2">
<Label htmlFor="escalation-team">Escalation team</Label>
<CascadeSelect id="escalation-team" options={TEAMS} placeholder="Choose a team" fluid />
</div>
)
}Disabled
A disabled field keeps its value and does not open; a disabled group or option stays in the menu and cannot be chosen.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const TEAMS: CascadeSelectOption[] = [
{
value: "support",
label: "Support",
children: [
{ value: "support-tier-1", label: "Tier 1" },
{ value: "support-tier-2", label: "Tier 2", disabled: true },
],
},
{
value: "billing",
label: "Billing",
disabled: true,
children: [{ value: "billing-refunds", label: "Refunds" }],
},
]
export default function CascadeSelectDisabled() {
return (
<div className="flex w-full max-w-64 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="locked-team">Owner team</Label>
<CascadeSelect id="locked-team" options={TEAMS} defaultValue="support-tier-1" disabled className="w-full" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="partly-disabled-team">Backup team</Label>
<CascadeSelect id="partly-disabled-team" options={TEAMS} placeholder="Choose a team" className="w-full" />
</div>
</div>
)
}Invalid
aria-invalid draws the error edge and a red placeholder; aria-describedby reads the message with the name.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const TEAMS: CascadeSelectOption[] = [
{
value: "support",
label: "Support",
children: [
{ value: "support-tier-1", label: "Tier 1" },
{ value: "support-tier-2", label: "Tier 2" },
],
},
{
value: "billing",
label: "Billing",
children: [{ value: "billing-refunds", label: "Refunds" }],
},
]
export default function CascadeSelectInvalid() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="routing-team">Route to</Label>
<CascadeSelect
id="routing-team"
options={TEAMS}
placeholder="Choose a team"
aria-invalid
aria-describedby="routing-team-error"
className="w-full"
/>
<p id="routing-team-error" className="text-xs text-destructive-strong">
Choose the team that receives new tickets.
</p>
</div>
)
}Loading options
Groups marked hasChildren load their level through loadOptions the first time they open, after a fixed delay here.
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"
const ORGANISATIONS: CascadeSelectOption[] = [
{ value: "acme", label: "Acme Mail", hasChildren: true },
{ value: "northwind", label: "Northwind Traders", hasChildren: true },
]
const PROJECTS: Record<string, CascadeSelectOption[]> = {
acme: [
{ value: "acme-newsletter", label: "Newsletter" },
{ value: "acme-receipts", label: "Receipts" },
],
northwind: [{ value: "northwind-alerts", label: "Stock alerts" }],
}
// A level that loads after a fixed delay, as a request to the app's API would.
function loadProjects(option: CascadeSelectOption) {
return new Promise<CascadeSelectOption[]>((resolve) => setTimeout(() => resolve(PROJECTS[option.value] ?? []), 800))
}
export default function CascadeSelectLoading() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="sending-project">Project</Label>
<CascadeSelect
id="sending-project"
options={ORGANISATIONS}
loadOptions={loadProjects}
placeholder="Choose a project"
className="w-full"
/>
</div>
)
}Accessibility
- Semantics
- The field is a
buttonwitharia-haspopup="menu"andaria-expanded, as a menu button is. Each level is arole="menu"; a group is amenuitemwitharia-haspopup="menu"andaria-expanded, and a leaf is amenuitemradio, the chosen one witharia-checked="true". A level that is loading carriesaria-busyand a disabled "Loading" item; a level that failed to load has a disabled item that says so. - Labels
- Name the field with a
Label htmlFor,aria-labelledbyoraria-label. A menu button would otherwise be named by its label alone; the field adds its own value to the name, so it is read as "Office, Los Angeles". The top menu is named by the same label. Put an error message inaria-describedby. - Focus
- The field is in the tab order. Opening moves focus into the menu (to the first option with the keyboard), or to the chosen option when there is one, with its levels open; closing returns focus to the field. While the menu is open the page behind it cannot be reached.
- Known limits
- Every level is a portal, outside the field's container, so CSS that targets the container does not reach it.
- There is no search box. Typing moves to the next option of the open level whose label starts with the letters typed.
- A group cannot be chosen itself; only leaves set the value.
- A value inside a level that has not been loaded yet shows as the raw value until that level loads.
- A long label is cut short with an ellipsis, in the field and in its level; the whole label stays the option's accessible name.
- Levels open side by side and flip to the other side at the window's edge; on a narrow phone screen a deep tree may need horizontal room it does not have.
- A clearable field sits in a wrapper (
data-slot="cascade-select-control") with its clear button beside it, since a button cannot hold another.
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpaceorโ | On the field, opens the menu and moves focus to the first option, or to the chosen one. |
| โorโ | Moves to the next or previous option of the level, wrapping at its ends. |
| โ | On a group, opens its level and moves focus to its first option (โ in right-to-left pages). |
| โ | Closes the current level and returns focus to its group (โ in right-to-left pages). |
| EnterorSpace | On a group, opens its level. On an option, chooses it, closes the menu and returns focus to the field. |
| HomeorEnd | Moves to the first or last option of the level. |
| AโZ | Type-ahead: moves to the next option of the level whose label starts with the letters typed. |
| Escape | Closes every level without changing the value, and returns focus to the field. |
| EnterorSpace | On the clear button, empties the field and returns focus to it. |
API
CascadeSelect
Renders a button and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | CascadeSelectOption[] | The top level of the tree. | |
clearable | boolean | false | Shows a clear button inside the field while a value is chosen. |
defaultOpen | boolean | false | Whether the menu starts open. |
defaultValue | string | The value it starts with, when it controls itself. | |
fluid | boolean | false | Fills the width of its container. |
loading | boolean | false | The top level is still loading: a spinner takes the chevron's place and the menu says so. |
loadOptions | ((option: CascadeSelectOption) => Promise<CascadeSelectOption[]>) | Fetches the children of a group marked hasChildren, the first time it opens. | |
onOpenChange | ((open: boolean) => void) | Called when the menu opens or closes. | |
onValueChange | ((value: string, path: CascadeSelectOption[]) => void) | Called with the new value and the options from the top level down to it; with "" and [] when cleared. | |
open | boolean | Whether the menu is open, when you control it. Pair it with onOpenChange. | |
placeholder | ReactNode | Shown while nothing is chosen, in the muted colour. | |
separator | string | / | What showPath puts between the levels. |
showPath | boolean | false | Shows every level of the chosen option ("United States / California / Los Angeles"), not only the leaf. |
size | "default" | "sm" | "lg" | 28, 35 or 42 px tall. Defaults to the provider's controlSize. | |
value | string | The chosen leaf's value, when you control it; "" means nothing is chosen. Pair it with onValueChange. | |
variant | "default" | "filled" | filled fills the field grey. Defaults to the provider's fieldVariant. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="cascade-select-trigger" (CascadeSelect), and data-size, data-variant, data-placeholder.
Provider strings: noResults, loadFailed, loading, clear (BooleanUIProvider's strings).
Theming
The field is the select field: --field (--field-filled with variant="filled"), the --control edge (--control-hover under the pointer), --ring on focus and while open, --invalid when invalid. Each level uses --popover and --popover-foreground, --accent for the focused option and for a group whose level is open, and --highlight for the chosen option (--highlight-focus while it has focus). Group chevrons are --control-hover. The levels' enter and exit motion is set once in theme.css.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--accent-foreground | text |
--control | border |
--control-hover | text, border |
--destructive-strong | text |
--field | background |
--field-disabled | background |
--field-disabled-foreground | text |
--field-filled | background |
--foreground | text |
--highlight | background |
--highlight-focus | background |
--highlight-foreground | text |
--invalid | border |
--muted-foreground | text |
--popover | background |
--popover-foreground | text |
--ring | border, outline |