Cascade Select
A Cascade Select component allows users to select from hierarchical data through multiple linked levels of dropdown menus.
Features
- Support for hierarchical data with unlimited depth levels
- Full keyboard navigation across all levels with arrow keys
- Support for single and multiple selections
- Support for both click and hover triggering modes
- Support for looping keyboard navigation
- Built-in accessibility with ARIA roles and keyboard interactions
- Support for disabled items and read-only state
- Form integration with hidden input element
- Support for Right to Left direction.
Installation
To use the cascade select machine in your project, run the following command in your command line:
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
To set up the cascade select correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
First, import the cascade select package into your project
import * as cascadeSelect from "@zag-js/cascade-select"
The cascade select package exports these functions:
machine— The state machine logic for the cascade select.connect— The function that translates the machine's state to JSX attributes and event handlers.collection- The function that creates a tree collection from a tree node.
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
You'll need to provide a unique
idto theuseMachinehook. This is used to ensure that every part has a unique identifier.
Pass the collection to the machine to create the cascade select 🔥
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"]], })
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
This behaviour ensures that the menu is closed when an item is selected and is
true by default. To keep the dropdown open after selection, set the
closeOnSelect property to false.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, closeOnSelect: false, })
Looping the keyboard navigation
When navigating with the cascade select using the arrow down and up keys, the
navigation stops at the first and last items. To loop navigation back to the
first or last item, set loopFocus: true in the machine's context.
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 => { value: string[], items: Item[] } console.log(details) }, })
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
When the cascade select is opened or closed, the onOpenChange callback is
called. You can listen for these events and do something with it.
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, onOpenChange(details) { // details => { open: boolean } console.log(details) }, })
Usage within a form
To use cascade select within a form, pass the name property to the machine
context. A hidden input element is automatically rendered using
getHiddenInputProps().
const service = useMachine(cascadeSelect.machine, { id: useId(), collection, name: "location", }) // In your JSX <input {...api.getHiddenInputProps()} />
Styling guide
Earlier, we mentioned that each cascade select part has a data-part attribute
added to them to select and style them in the DOM.
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 itemgetValueTextProps() => T["element"]Returns the props for the value text element
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.