Cascade Select
A cascade select component allows users to select from hierarchical data through multiple linked levels of dropdown menus.
Features
- Supports hierarchical data with unlimited depth levels
- Full keyboard navigation across all levels with arrow keys
- Supports single and multiple selections
- Supports both click and hover triggering modes
- Supports looping keyboard navigation
- Built-in accessibility with ARIA roles and keyboard interactions
- Supports disabled items and read-only state
- Form integration with hidden input element
- Supports right-to-left direction
Installation
Install the cascade select package:
npm install @zag-js/cascade-select @zag-js/react # or yarn add @zag-js/cascade-select @zag-js/react
npm install @zag-js/cascade-select @zag-js/solid # or yarn add @zag-js/cascade-select @zag-js/solid
npm install @zag-js/cascade-select @zag-js/vue # or yarn add @zag-js/cascade-select @zag-js/vue
npm install @zag-js/cascade-select @zag-js/svelte # or yarn add @zag-js/cascade-select @zag-js/svelte
Anatomy
Check the cascade select anatomy and part names.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
Import the cascade select package:
import * as cascadeSelect from "@zag-js/cascade-select"
These are the key exports:
machine- State machine logic.connect- Maps machine state to JSX props and event handlers.collection- Creates a tree collection from your hierarchical data.
Create the collection
Use the collection function to create a tree collection from your hierarchical
data. Pass a rootNode along with functions to extract each node's value,
string label, and children.
import * as cascadeSelect from "@zag-js/cascade-select" interface Node { label: string value: string children?: Node[] } const collection = cascadeSelect.collection<Node>({ nodeToValue: (node) => node.value, nodeToString: (node) => node.label, nodeToChildren: (node) => node.children, rootNode: { label: "ROOT", value: "ROOT", children: [ { label: "North America", value: "north-america", children: [ { label: "United States", value: "us", children: [ { label: "New York", value: "ny" }, { label: "California", value: "ca" }, ], }, { label: "Canada", value: "canada" }, ], }, { label: "Africa", value: "africa", children: [ { label: "Nigeria", value: "ng" }, { label: "Kenya", value: "ke" }, ], }, ], }, })
Create the cascade select
Pass a unique
idtouseMachineso generated element ids stay predictable.
Then use the framework integration helpers:
import * as cascadeSelect from "@zag-js/cascade-select" import { normalizeProps, Portal, useMachine } from "@zag-js/react" import { JSX, useId } from "react" // 1. Create the collection (see above) const collection = cascadeSelect.collection<Node>({ // ... }) // 2. Create the recursive tree node interface TreeNodeProps { node: Node indexPath?: number[] value?: string[] api: cascadeSelect.Api } const TreeNode = (props: TreeNodeProps): JSX.Element => { const { node, indexPath = [], value = [], api } = props const nodeProps = { indexPath, value, item: node } const nodeState = api.getItemState(nodeProps) const children = collection.getNodeChildren(node) return ( <> <ul {...api.getListProps(nodeProps)}> {children.map((item, index) => { const itemProps = { indexPath: [...indexPath, index], value: [...value, collection.getNodeValue(item)], item, } const itemState = api.getItemState(itemProps) return ( <li key={collection.getNodeValue(item)} {...api.getItemProps(itemProps)}> <span {...api.getItemTextProps(itemProps)}>{item.label}</span> {itemState.hasChildren && <span>›</span>} <span {...api.getItemIndicatorProps(itemProps)}>✓</span> </li> ) })} </ul> {nodeState.highlightedChild && collection.isBranchNode(nodeState.highlightedChild) && ( <TreeNode node={nodeState.highlightedChild} api={api} indexPath={[...indexPath, nodeState.highlightedIndex]} value={[...value, collection.getNodeValue(nodeState.highlightedChild)]} /> )} </> ) } // 3. Create the cascade select export function CascadeSelect() { const service = useMachine(cascadeSelect.machine, { id: useId(), collection, }) const api = cascadeSelect.connect(service, normalizeProps) return ( <div {...api.getRootProps()}> <label {...api.getLabelProps()}>Location</label> <div {...api.getControlProps()}> <button {...api.getTriggerProps()}> <span>{api.valueAsString || "Select location"}</span> <span {...api.getIndicatorProps()}>▼</span> </button> <button {...api.getClearTriggerProps()}>✕</button> </div> <Portal> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> <TreeNode node={collection.rootNode} api={api} /> </div> </div> </Portal> </div> ) }
import * as cascadeSelect from "@zag-js/cascade-select" import { normalizeProps, useMachine } from "@zag-js/solid" import { Index, createMemo, createUniqueId, JSX } from "solid-js" import { Portal } from "solid-js/web" // 1. Create the collection (see above) const collection = cascadeSelect.collection<Node>({ // ... }) // 2. Create the recursive tree node interface TreeNodeProps { node: Node indexPath?: number[] value?: string[] api: cascadeSelect.Api } function TreeNode(props: TreeNodeProps): JSX.Element { const indexPath = () => props.indexPath ?? [] const value = () => props.value ?? [] const nodeProps = () => ({ indexPath: indexPath(), value: value(), item: props.node }) const nodeState = () => props.api.getItemState(nodeProps()) const children = () => collection.getNodeChildren(props.node) return ( <> <ul {...props.api.getListProps(nodeProps())}> <Index each={children()}> {(item, index) => { const itemProps = () => ({ indexPath: [...indexPath(), index], value: [...value(), collection.getNodeValue(item())], item: item(), }) const itemState = () => props.api.getItemState(itemProps()) return ( <li {...props.api.getItemProps(itemProps())}> <span {...props.api.getItemTextProps(itemProps())}>{item().label}</span> {itemState().hasChildren && <span>›</span>} <span {...props.api.getItemIndicatorProps(itemProps())}>✓</span> </li> ) }} </Index> </ul> {nodeState().highlightedChild && collection.isBranchNode(nodeState().highlightedChild) && ( <TreeNode node={nodeState().highlightedChild} api={props.api} indexPath={[...indexPath(), nodeState().highlightedIndex]} value={[...value(), collection.getNodeValue(nodeState().highlightedChild)]} /> )} </> ) } // 3. Create the cascade select export function CascadeSelect() { const service = useMachine(cascadeSelect.machine, { id: createUniqueId(), collection, }) const api = createMemo(() => cascadeSelect.connect(service, normalizeProps)) return ( <div {...api().getRootProps()}> <label {...api().getLabelProps()}>Location</label> <div {...api().getControlProps()}> <button {...api().getTriggerProps()}> <span>{api().valueAsString || "Select location"}</span> <span {...api().getIndicatorProps()}>▼</span> </button> <button {...api().getClearTriggerProps()}>✕</button> </div> <Portal> <div {...api().getPositionerProps()}> <div {...api().getContentProps()}> <TreeNode node={collection.rootNode} api={api()} /> </div> </div> </Portal> </div> ) }
<!-- CascadeSelectNode.vue --> <script setup lang="ts"> import { computed } from "vue" const props = defineProps(["node", "indexPath", "value", "api", "collection"]) const nodeProps = computed(() => ({ indexPath: props.indexPath, value: props.value, item: props.node, })) const nodeState = computed(() => props.api.getItemState(nodeProps.value)) const children = computed(() => props.collection.getNodeChildren(props.node)) </script> <template> <ul v-bind="api.getListProps(nodeProps)"> <li v-for="(item, index) in children" :key="collection.getNodeValue(item)" v-bind="api.getItemProps({ indexPath: [...indexPath, index], value: [...value, collection.getNodeValue(item)], item })" > <span v-bind="api.getItemTextProps({ indexPath: [...indexPath, index], value: [...value, collection.getNodeValue(item)], item })"> {{ item.label }} </span> <span>›</span> <span v-bind="api.getItemIndicatorProps({ indexPath: [...indexPath, index], value: [...value, collection.getNodeValue(item)], item })">✓</span> </li> </ul> <CascadeSelectNode v-if="nodeState.highlightedChild && collection.isBranchNode(nodeState.highlightedChild)" :node="nodeState.highlightedChild" :api="api" :collection="collection" :index-path="[...indexPath, nodeState.highlightedIndex]" :value="[...value, collection.getNodeValue(nodeState.highlightedChild)]" /> </template>
<!-- CascadeSelect.vue --> <script setup lang="ts"> import * as cascadeSelect from "@zag-js/cascade-select" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, Teleport } from "vue" // 1. Create the collection (see above) const collection = cascadeSelect.collection<Node>({ // ... }) const service = useMachine(cascadeSelect.machine, { id: "1", collection, }) const api = computed(() => cascadeSelect.connect(service, normalizeProps)) </script> <template> <div v-bind="api.getRootProps()"> <label v-bind="api.getLabelProps()">Location</label> <div v-bind="api.getControlProps()"> <button v-bind="api.getTriggerProps()"> <span>{{ api.valueAsString || "Select location" }}</span> <span v-bind="api.getIndicatorProps()">▼</span> </button> <button v-bind="api.getClearTriggerProps()">✕</button> </div> <Teleport to="body"> <div v-bind="api.getPositionerProps()"> <div v-bind="api.getContentProps()"> <CascadeSelectNode :node="collection.rootNode" :api="api" :collection="collection" :index-path="[]" :value="[]" /> </div> </div> </Teleport> </div> </template>
<script lang="ts"> import * as cascadeSelect from "@zag-js/cascade-select" import { normalizeProps, portal, useMachine } from "@zag-js/svelte" // 1. Create the collection (see above) interface Node { label: string value: string children?: Node[] } const collection = cascadeSelect.collection<Node>({ // ... }) // 2. Create the recursive tree node const id = $props.id() const service = useMachine(cascadeSelect.machine, { id, collection }) const api = $derived(cascadeSelect.connect(service, normalizeProps)) </script> {#snippet treeNode(nodeProps)} {@const { node, indexPath, value, api } = nodeProps} {@const listNodeProps = { indexPath, value, item: node }} {@const nodeState = api.getItemState(listNodeProps)} {@const children = collection.getNodeChildren(node)} <ul {...api.getListProps(listNodeProps)}> {#each children as item, index} {@const itemProps = { indexPath: [...indexPath, index], value: [...value, collection.getNodeValue(item)], item, }} {@const itemState = api.getItemState(itemProps)} <li {...api.getItemProps(itemProps)}> <span {...api.getItemTextProps(itemProps)}>{item.label}</span> {#if itemState.hasChildren}<span>›</span>{/if} <span {...api.getItemIndicatorProps(itemProps)}>✓</span> </li> {/each} </ul> {#if nodeState.highlightedChild && collection.isBranchNode(nodeState.highlightedChild)} {@render treeNode({ node: nodeState.highlightedChild, api, indexPath: [...indexPath, nodeState.highlightedIndex], value: [...value, collection.getNodeValue(nodeState.highlightedChild)], })} {/if} {/snippet} <!-- 3. Create the cascade select --> <div {...api.getRootProps()}> <label {...api.getLabelProps()}>Location</label> <div {...api.getControlProps()}> <button {...api.getTriggerProps()}> <span>{api.valueAsString || "Select location"}</span> <span {...api.getIndicatorProps()}>▼</span> </button> <button {...api.getClearTriggerProps()}>✕</button> </div> <div use:portal {...api.getPositionerProps()}> <div {...api.getContentProps()}> {@render treeNode({ node: collection.rootNode, api, indexPath: [], value: [] })} </div> </div> </div>
Setting the initial value
Use the defaultValue property to set the initial value of the cascade select.
The
valueproperty must be an array of string paths. Each path is an array of values from the root to the selected leaf item.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, defaultValue: [["north-america", "us", "ny"]], })
Controlled selection
Use value and onValueChange for controlled selection state.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, value, onValueChange(details) { setValue(details.value) }, })
Selecting multiple values
To allow selecting multiple values, set the multiple property to true.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, multiple: true, })
Hover triggering
By default, items are highlighted when clicked. To highlight items on hover
instead (like a traditional cascading menu), set the highlightTrigger property
to "hover".
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, highlightTrigger: "hover", })
Allowing parent selection
By default, only leaf nodes (items without children) can be selected. To allow
parent (branch) nodes to also be selectable, set allowParentSelection to
true.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, allowParentSelection: true, })
Close on select
By default, the menu closes when you select an item. Set closeOnSelect to
false to keep it open.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, closeOnSelect: false, })
Looping the keyboard navigation
By default, arrow-key navigation stops at the first and last items. Set
loopFocus: true to loop back around.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, loopFocus: true, })
Listening for highlight changes
When an item is highlighted with the pointer or keyboard, use the
onHighlightChange to listen for the change and do something with it.
const service = useMachine(cascadeSelect.machine, { id: useId(), onHighlightChange(details) { // details => { highlightedValue: string[], highlightedItems: Item[] } console.log(details) }, })
Setting the initial highlighted path
Use defaultHighlightedValue to set the initially highlighted path.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, defaultHighlightedValue: ["north-america", "us"], })
Controlled highlighted path
Use highlightedValue and onHighlightChange to control highlighting
externally.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, highlightedValue, onHighlightChange(details) { setHighlightedValue(details.highlightedValue) }, })
Listening for selection changes
When an item is selected, use the onValueChange property to listen for the
change and do something with it.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, onValueChange(details) { // details => { value: string[][], items: Item[][] } console.log(details) }, })
Listening for open and close events
Use onOpenChange to listen for open and close events.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, onOpenChange(details) { // details => { open: boolean, value: string[][] } console.log(details) }, })
Controlling open state
Use open and onOpenChange for controlled open state, or defaultOpen for an
uncontrolled initial state.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, open, onOpenChange({ open }) { setOpen(open) }, })
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, defaultOpen: true, })
Positioning submenu panels
Use positioning to configure placement and collision behavior.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, positioning: { placement: "right-start", gutter: 4, }, })
Custom scroll behavior
Use scrollToIndexFn to customize how each level scrolls highlighted items into
view.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, scrollToIndexFn(details) { // details => { index, depth, immediate? } customScroll(details) }, })
Customizing the trigger label
Use formatValue to control how selected paths are rendered in the trigger.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, formatValue(selectedItems) { return selectedItems .map((path) => path.map((item) => item.label).join(" / ")) .join(", ") }, })
Usage within a form
To use cascade select in a form, pass name. A hidden input is rendered with
getHiddenInputProps().
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, name: "location", }) // In your JSX <input {...api.getHiddenInputProps()} />
If the hidden input belongs to a different form element, also set form.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, name: "location", form: "checkout-form", })
Validation and read-only state
Use readOnly, required, and invalid to control interaction and form state.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, readOnly: true, required: true, invalid: false, })
Styling guide
Each cascade select part includes a data-part attribute you can target in CSS.
Open and closed state
When the cascade select is open, the trigger and content is given a data-state
attribute.
[data-part="trigger"][data-state="open|closed"] { /* styles for open or closed state */ } [data-part="content"][data-state="open|closed"] { /* styles for open or closed state */ }
Selected state
Items are given a data-state attribute, indicating whether they are selected.
[data-part="item"][data-state="checked|unchecked"] { /* styles for selected or unselected state */ }
Highlighted state
When an item is highlighted, via keyboard navigation or pointer, it is given a
data-highlighted attribute.
[data-part="item"][data-highlighted] { /* styles for highlighted state */ }
Branch items
When an item has children (is a branch node), it is given a data-has-children
attribute.
[data-part="item"][data-has-children] { /* styles for items with children (branch nodes) */ }
Invalid state
When the cascade select is invalid, the label and trigger is given a
data-invalid attribute.
[data-part="label"][data-invalid] { /* styles for invalid state */ } [data-part="trigger"][data-invalid] { /* styles for invalid state */ }
Disabled state
When the cascade select is disabled, the trigger and label is given a
data-disabled attribute.
[data-part="trigger"][data-disabled] { /* styles for disabled state */ } [data-part="label"][data-disabled] { /* styles for disabled label state */ } [data-part="item"][data-disabled] { /* styles for disabled item state */ }
Empty state
When no option is selected, the trigger is given a data-placeholder-shown
attribute.
[data-part="trigger"][data-placeholder-shown] { /* styles for empty state */ }
Methods and Properties
Machine Context
The cascade select machine exposes the following context properties:
collectionTreeCollection<T>The tree collection dataidsPartial<{ root: string; label: string; control: string; trigger: string; indicator: string; clearTrigger: string; positioner: string; content: string; hiddenInput: string; list(valuePath: string): string; item(valuePath: string): string; }>The ids of the cascade-select elements. Useful for composition.namestringThe name attribute of the underlying input elementformstringThe form attribute of the underlying input elementvaluestring[][]The controlled value of the cascade-selectdefaultValuestring[][]The initial value of the cascade-select when rendered. Use when you don't need to control the value.highlightedValuestring[]The controlled highlighted value of the cascade-selectdefaultHighlightedValuestring[]The initial highlighted value of the cascade-select when rendered.multiplebooleanWhether to allow multiple selectionsopenbooleanThe controlled open state of the cascade-selectdefaultOpenbooleanThe initial open state of the cascade-select when rendered. Use when you don't need to control the open state.highlightTrigger"click" | "hover"What triggers highlighting of itemscloseOnSelectbooleanWhether the cascade-select should close when an item is selectedloopFocusbooleanWhether the cascade-select should loop focus when navigating with keyboarddisabledbooleanWhether the cascade-select is disabledreadOnlybooleanWhether the cascade-select is read-onlyrequiredbooleanWhether the cascade-select is requiredinvalidbooleanWhether the cascade-select is invalidpositioningPositioningOptionsThe positioning options for the cascade-select contentscrollToIndexFn(details: ScrollToIndexDetails) => voidFunction to scroll to a specific index in a listformatValue(selectedItems: T[][]) => stringFunction to format the display valueonValueChange(details: ValueChangeDetails<T>) => voidCalled when the value changesonHighlightChange(details: HighlightChangeDetails<T>) => voidCalled when the highlighted value changesonOpenChange(details: OpenChangeDetails) => voidCalled when the open state changesallowParentSelectionbooleanWhether parent (branch) items can be selectabledir"ltr" | "rtl"The document's text/writing direction.idstringThe unique identifier of the machine.getRootNode() => ShadowRoot | Node | DocumentA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.onPointerDownOutside(event: PointerDownOutsideEvent) => voidFunction called when the pointer is pressed down outside the componentonFocusOutside(event: FocusOutsideEvent) => voidFunction called when the focus is moved outside the componentonInteractOutside(event: InteractOutsideEvent) => voidFunction called when an interaction happens outside the component
Machine API
The cascade select api exposes the following methods:
collectionTreeCollection<V>The tree collection dataopenbooleanWhether the cascade-select is openfocusedbooleanWhether the cascade-select is focusedmultiplebooleanWhether the cascade-select allows multiple selectionsdisabledbooleanWhether the cascade-select is disabledhighlightedValuestring[]The value of the highlighted itemhighlightedItemsV[]The items along the highlighted pathselectedItemsV[][]The selected itemshasSelectedItemsbooleanWhether there's a selected optionemptybooleanWhether the cascade-select value is emptyvaluestring[][]The current value of the cascade-selectvalueAsStringstringThe current value as textfocus() => voidFunction to focus on the select inputreposition(options?: Partial<PositioningOptions>) => voidFunction to set the positioning options of the cascade-selectsetOpen(open: boolean) => voidFunction to open the cascade-selectsetHighlightValue(value: string | string[]) => voidFunction to set the highlighted value (path or single value to find)clearHighlightValue() => voidFunction to clear the highlighted valueselectValue(value: string[]) => voidFunction to select a valuesetValue(value: string[][]) => voidFunction to set the valueclearValue(value?: string[]) => voidFunction to clear the valuegetItemState(props: ItemProps<V>) => ItemState<V>Returns the state of a cascade-select item
Data Attributes
CSS Variables
Accessibility
Adheres to the ListBox WAI-ARIA design pattern.
Keyboard Interactions
- SpaceWhen focus is on trigger, opens the cascade select and focuses the first item.
When focus is on the content, selects the highlighted item. - EnterWhen focus is on trigger, opens the cascade select and focuses the first item.
When focus is on content, selects the highlighted item. - ArrowDownWhen focus is on trigger, opens the cascade select.
When focus is on content, moves focus to the next item in the current level. - ArrowUpWhen focus is on trigger, opens the cascade select and focuses the last item.
When focus is on content, moves focus to the previous item in the current level. - ArrowRightWhen focus is on a branch item, expands the next level and moves focus into it.
- ArrowLeftWhen focus is on a nested level, collapses it and moves focus back to the parent.
When focus is at the root level, closes the cascade select. - HomeMoves focus to the first item in the current level.
- EndMoves focus to the last item in the current level.
- EscCloses the cascade select and moves focus to trigger.