Select
A select component lets you pick a value from predefined options.
Features
- Supports single and multiple selection
- Supports typeahead, keyboard navigation, and RTL
- Supports controlled open, value, and highlight state
- Supports form submission and browser autofill
Installation
Install the select package:
npm install @zag-js/select @zag-js/react # or yarn add @zag-js/select @zag-js/react
npm install @zag-js/select @zag-js/solid # or yarn add @zag-js/select @zag-js/solid
npm install @zag-js/select @zag-js/vue # or yarn add @zag-js/select @zag-js/vue
npm install @zag-js/select @zag-js/svelte # or yarn add @zag-js/select @zag-js/svelte
Anatomy
Check the select anatomy and part names.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
Import the select package:
import * as select from "@zag-js/select"
These are the key exports:
machine- State machine logic.connect- Maps machine state to JSX props and event handlers.collection- Creates a collection interface from an array of items.
Then use the framework integration helpers:
import * as select from "@zag-js/select" import { useMachine, normalizeProps, Portal } from "@zag-js/react" import { useId, useRef } from "react" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, { label: "Korea", value: "KO" }, { label: "Kenya", value: "KE" }, { label: "United Kingdom", value: "UK" }, { label: "Ghana", value: "GH" }, { label: "Uganda", value: "UG" }, ] export function Select() { const collection = select.collection({ items: selectData, itemToString: (item) => item.label, itemToValue: (item) => item.value, }) const service = useMachine(select.machine, { id: useId(), collection, }) const api = select.connect(service, normalizeProps) return ( <div {...api.getRootProps()}> <div {...api.getControlProps()}> <label {...api.getLabelProps()}>Label</label> <button {...api.getTriggerProps()}> {api.valueAsString || "Select option"} </button> </div> <Portal> <div {...api.getPositionerProps()}> <ul {...api.getContentProps()}> {selectData.map((item) => ( <li key={item.value} {...api.getItemProps({ item })}> <span>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </Portal> </div> ) }
import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, //... ] export function Select() { const service = useMachine(select.machine, { id: createUniqueId(), collection: select.collection({ items: selectData, }), }) const api = createMemo(() => select.connect(service, normalizeProps)) return ( <div> <div> <label {...api().getLabelProps()}>Label</label> <button {...api().getTriggerProps()}> <span>{api().valueAsString || "Select option"}</span> </button> </div> <div {...api().getPositionerProps()}> <ul {...api().getContentProps()}> {selectData.map((item) => ( <li key={item.value} {...api().getItemProps({ item })}> <span>{item.label}</span> <span {...api().getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </div> ) }
<script setup> import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, defineComponent, Teleport } from "vue" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, //... ] const service = useMachine(select.machine, { id: "1", collection: select.collection({ items: selectData, }), }) const api = computed(() => select.connect(service, normalizeProps)) </script> <template> <div> <label v-bind="api.getLabelProps()">Label</label> <button v-bind="api.getTriggerProps()"> <span>{{ api.valueAsString || "Select option" }}</span> <span>▼</span> </button> </div> <Teleport to="body"> <div v-bind="api.getPositionerProps()"> <ul v-bind="api.getContentProps()"> <li v-for="item in selectData" :key="item.value" v-bind="api.getItemProps({ item })" > <span>{{ item.label }}</span> <span v-bind="api.getItemIndicatorProps({ item })">✓</span> </li> </ul> </div> </Teleport> </template>
<script lang="ts"> import * as select from "@zag-js/select" import { portal, useMachine, normalizeProps } from "@zag-js/svelte" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, { label: "Korea", value: "KO" }, { label: "Kenya", value: "KE" }, { label: "United Kingdom", value: "UK" }, { label: "Ghana", value: "GH" }, { label: "Uganda", value: "UG" }, ] const collection = select.collection({ items: selectData, itemToString: (item) => item.label, itemToValue: (item) => item.value, }) const id = $props.id() const service = useMachine(select.machine, { id, collection, }) const api = $derived(select.connect(service, normalizeProps)) </script> <div {...api.getRootProps()}> <div {...api.getControlProps()}> <label {...api.getLabelProps()}>Label</label> <button {...api.getTriggerProps()}> {api.valueAsString || "Select option"} </button> </div> <div use:portal {...api.getPositionerProps()}> <ul {...api.getContentProps()}> {#each selectData as item} <li {...api.getItemProps({ item })}> <span {...api.getItemTextProps({ item })}>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </li> {/each} </ul> </div> </div>
Setting the initial value
Use the defaultValue property to set the initial value of the select.
The
valueproperty must be an array of strings. If selecting a single value, pass an array with a single string.
const collection = select.collection({ items: [ { label: "Nigeria", value: "ng" }, { label: "Ghana", value: "gh" }, { label: "Kenya", value: "ke" }, //... ], }) const service = useMachine(select.machine, { id: useId(), collection, defaultValue: ["ng"], })
Selecting multiple values
Set multiple to true to allow selecting multiple values.
const service = useMachine(select.machine, { id: useId(), collection, multiple: true, })
Controlled select value
Use value and onValueChange for controlled selection.
const service = useMachine(select.machine, { id: useId(), collection, value, onValueChange(details) { setValue(details.value) }, })
Using a custom object format
By default, the select collection expects an array of items with label and
value properties. To use a custom object format, pass the itemToString and
itemToValue properties to the collection function.
itemToString— A function that returns the string representation of an item. Used to compare items when filtering.itemToValue— A function that returns the unique value of an item.itemToDisabled— A function that returns the disabled state of an item.groupBy— A function that returns the group of an item.groupSort— An array or function to sort the groups.
const collection = select.collection({ // custom object format items: [ { id: 1, fruit: "Banana", available: true, quantity: 10 }, { id: 2, fruit: "Apple", available: false, quantity: 5 }, { id: 3, fruit: "Orange", available: true, quantity: 3 }, //... ], // convert item to string itemToString(item) { return item.fruit }, // convert item to value itemToValue(item) { return item.id }, // convert item to disabled state itemToDisabled(item) { return !item.available || item.quantity === 0 }, groupBy(item) { return item.available ? "available" : "unavailable" }, groupSort: ["available", "unavailable"], }) // use the collection const service = useMachine(select.machine, { id: useId(), collection, })
Usage within a form
To use select in a form, set name and render api.getHiddenSelectProps().
import * as select from "@zag-js/select" import { useMachine, normalizeProps, Portal } from "@zag-js/react" import { useId } from "react" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, { label: "Korea", value: "KO" }, { label: "Kenya", value: "KE" }, { label: "United Kingdom", value: "UK" }, { label: "Ghana", value: "GH" }, { label: "Uganda", value: "UG" }, ] export function SelectWithForm() { const service = useMachine(select.machine, { id: useId(), collection: select.collection({ items: selectData }), name: "country", }) const api = select.connect(service, normalizeProps) return ( <form> {/* Hidden select */} <select {...api.getHiddenSelectProps()}> {selectData.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {/* Custom Select */} <div {...api.getControlProps()}> <label {...api.getLabelProps()}>Label</label> <button type="button" {...api.getTriggerProps()}> <span>{api.valueAsString || "Select option"}</span> <CaretIcon /> </button> </div> <Portal> <div {...api.getPositionerProps()}> <ul {...api.getContentProps()}> {selectData.map((item) => ( <li key={item.value} {...api.getItemProps({ item })}> <span>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </Portal> </form> ) }
import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, //... ] export function SelectWithForm() { const service = useMachine( select.machine, ({ collection: select.collection({ items: selectData, }), id: createUniqueId(), name: "country", }), )Ø const api = createMemo(() => select.connect(service, normalizeProps)) return ( <form> <div {...api().getRootProps()}> {/* Hidden select */} <select {...api().getHiddenSelectProps()}> {selectData.map((item) => ( <option key={item.value} value={item.value}> {item.label} </option> ))} </select> {/* Custom Select */} <div {...api().getControlProps()}> <label {...api().getLabelProps()}>Label</label> <button type="button" {...api().getTriggerProps()}> <span>{api().valueAsString || "Select option"}</span> <CaretIcon /> </button> </div> <Portal> <div {...api().getPositionerProps()}> <ul {...api().getContentProps()}> {selectData.map((item) => ( <li key={item.value} {...api().getItemProps({ item })}> <span>{item.label}</span> <span {...api().getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </Portal> </div> </form> ) }
<script setup> import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/vue" import { Teleport } from "vue" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, //... ] const service = useMachine(select.machine, { id: "1", collection: select.collection({ items: selectData, }), name: "country", }) const api = computed(() => select.connect(service, normalizeProps)) </script> <template> <form> <!-- Hidden select --> <select v-bind="api.getHiddenSelectProps()"> <option v-for="item in selectData" :key="item.value" :value="item.value"> {{ item.label }} </option> </select> <!-- Custom Select --> <div v-bind="api.getControlProps()"> <label v-bind="api.getLabelProps()">Label</label> <button type="button" v-bind="api.getTriggerProps()"> <span>{{ api.valueAsString || "Select option" }}</span> <span>▼</span> </button> </div> <Teleport to="body"> <div v-bind="api.getPositionerProps()"> <ul v-bind="api.getContentProps()"> <li v-for="item in selectData" :key="item.value" v-bind="api.getItemProps({ item })" > <span>{{ label }}</span> <span v-bind="api.getItemIndicatorProps({ item })">✓</span> </li> </ul> </div> </Teleport> </form> </template>
<script lang="ts"> import * as select from "@zag-js/select" import { portal, normalizeProps, useMachine } from "@zag-js/svelte" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, { label: "Korea", value: "KO" }, { label: "Kenya", value: "KE" }, { label: "United Kingdom", value: "UK" }, { label: "Ghana", value: "GH" }, { label: "Uganda", value: "UG" }, ] const service = useMachine(select.machine, { id: "1", collection: select.collection({ items: selectData }), name: "country", }) const api = $derived(select.connect(service, normalizeProps)) </script> <form> <!-- Hidden select --> <select {...api.getHiddenSelectProps()}> {#each selectData as option} <option value={option.value}> {option.label} </option> {/each} </select> <!-- Custom Select --> <div {...api.getControlProps()}> <label {...api.getLabelProps()}>Label</label> <button type="button" {...api.getTriggerProps()}> <span>{api.valueAsString || "Select option"}</span> <CaretIcon /> </button> </div> <div use:portal {...api.getPositionerProps()}> <ul {...api.getContentProps()}> {#each selectData as item} <li {...api.getItemProps({ item })}> <span>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </li> {/each} </ul> </div> </form>
Browser autofill support
To support browser autofill for form fields like state or province, set
autoComplete on the machine.
const service = useMachine(select.machine, { id: useId(), collection, name: "state", autoComplete: "address-level1", })
Disabling an item
To disable a select item, use itemToDisabled in the collection.
const collection = select.collection({ items: countries, itemToDisabled(item) { return item.disabled }, }) const service = useMachine(select.machine, { id: useId(), collection, })
Close on select
By default, the menu closes when you select an item with pointer, space, or
enter. Set closeOnSelect to false to keep it open.
const service = useMachine(select.machine, { id: useId(), collection, closeOnSelect: false, })
Programmatic selection control
Use the API for imperative updates.
api.selectValue("ng") api.setValue(["ng", "ke"]) api.clearValue() // or api.clearValue("ng")
Controlling open state
Use open and onOpenChange for controlled open state, or defaultOpen for
uncontrolled initial state.
const service = useMachine(select.machine, { id: useId(), collection, open, onOpenChange(details) { setOpen(details.open) // details => { open: boolean, value: string[] } }, })
const service = useMachine(select.machine, { id: useId(), collection, defaultOpen: true, })
Controlling highlighted item
Use highlightedValue and onHighlightChange to manage item highlight.
const service = useMachine(select.machine, { id: useId(), collection, highlightedValue, onHighlightChange(details) { setHighlightedValue(details.highlightedValue) // details => { highlightedValue, highlightedItem, highlightedIndex } }, })
Positioning the popup
Use positioning to control popup placement and behavior.
const service = useMachine(select.machine, { id: useId(), collection, positioning: { placement: "bottom-start" }, })
Looping the keyboard navigation
By default, arrow key navigation stops at the first and last options. Set
loopFocus: true to loop back around.
const service = useMachine(select.machine, { id: useId(), collection, loopFocus: true, })
Allowing deselection in single-select mode
Set deselectable to allow clicking the selected item again to clear the value.
const service = useMachine(select.machine, { id: useId(), collection, deselectable: true, })
Listening for highlight changes
Use onHighlightChange to listen for highlighted item changes.
const service = useMachine(select.machine, { id: useId(), collection, onHighlightChange(details) { // details => { highlightedValue, highlightedItem, highlightedIndex } console.log(details) }, })
Listening for selection changes
Use onValueChange to listen for selected item changes.
const service = useMachine(select.machine, { id: useId(), collection, onValueChange(details) { // details => { value: string[], items: Item[] } console.log(details) }, })
Listening for item selection
Use onSelect when you need the selected item value immediately.
const service = useMachine(select.machine, { id: useId(), collection, onSelect(details) { // details => { value: string } console.log(details.value) }, })
Listening for open and close events
Use onOpenChange to listen for open and close events.
const service = useMachine(select.machine, { id: useId(), collection, onOpenChange(details) { // details => { open: boolean, value: string[] } console.log(details.open) }, })
Grouping items
The select relies on the collection, so rendered items must match collection items.
Set groupBy on the collection to define item groups.
const collection = select.collection({ items: [], itemToValue: (item) => item.value, itemToString: (item) => item.label, groupBy: (item) => item.group || "default", })
Then, use the collection.group() method to render the grouped items.
{ collection.group().map(([group, items], index) => ( <div key={`${group}-${index}`}> <div {...api.getItemGroupProps({ id: group })}>{group}</div> {items.map((item, index) => ( <div key={`${item.value}-${index}`} {...api.getItemProps({ item })}> <span {...api.getItemTextProps({ item })}>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </div> ))} </div> )) }
Usage with large data
For large lists, combine select with a virtualization library like
react-window or @tanstack/react-virtual.
Example with @tanstack/react-virtual:
function Demo() { const selectData = [] const contentRef = useRef(null) const rowVirtualizer = useVirtualizer({ count: selectData.length, getScrollElement: () => contentRef.current, estimateSize: () => 32, }) const service = useMachine(select.machine, { id: useId(), collection, scrollToIndexFn(details) { rowVirtualizer.scrollToIndex(details.index, { align: "center", behavior: "auto", }) }, }) const api = select.connect(service, normalizeProps) return ( <div {...api.getRootProps()}> {/* ... */} <Portal> <div {...api.getPositionerProps()}> <div ref={contentRef} {...api.getContentProps()}> <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, width: "100%", position: "relative", }} > {rowVirtualizer.getVirtualItems().map((virtualItem) => { const item = selectData[virtualItem.index] return ( <div key={item.value} {...api.getItemProps({ item })} style={{ position: "absolute", top: 0, left: 0, width: "100%", height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", }} > <span>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </div> ) })} </div> </div> </div> </Portal> </div> ) }
Usage within dialog
When using select in a dialog, avoid rendering it in a Portal or Teleport
outside the dialog focus scope.
Styling guide
Each select part includes a data-part attribute you can target in CSS.
Open and closed state
When the 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 */ }
Invalid state
When the 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 select is disabled, the trigger and label is given a data-disabled
attribute.
[data-part="trigger"][data-disabled] { /* styles for disabled select state */ } [data-part="label"][data-disabled] { /* styles for disabled label state */ } [data-part="item"][data-disabled] { /* styles for disabled option state */ }
Optionally, when an item is disabled, it is given a
data-disabledattribute.
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 select state */ }
Methods and Properties
Machine Context
The select machine exposes the following context properties:
collectionListCollection<T>The item collectionidsPartial<{ root: string; content: string; control: string; trigger: string; clearTrigger: string; label: string; hiddenSelect: string; positioner: string; item: (id: string | number) => string; itemGroup: (id: string | number) => string; itemGroupLabel: (id: string | number) => string; }>The ids of the elements in the select. Useful for composition.namestringThe `name` attribute of the underlying select.formstringThe associate form of the underlying select.autoCompletestringThe autocomplete attribute for the hidden select. Enables browser autofill (e.g. "address-level1" for state).disabledbooleanWhether the select is disabledinvalidbooleanWhether the select is invalidreadOnlybooleanWhether the select is read-onlyrequiredbooleanWhether the select is requiredcloseOnSelectbooleanWhether the select should close after an item is selectedonSelect(details: SelectionDetails) => voidFunction called when an item is selectedonHighlightChange(details: HighlightChangeDetails<T>) => voidThe callback fired when the highlighted item changes.onValueChange(details: ValueChangeDetails<T>) => voidThe callback fired when the selected item changes.onOpenChange(details: OpenChangeDetails) => voidFunction called when the popup is openedpositioningPositioningOptionsThe positioning options of the menu.valuestring[]The controlled keys of the selected itemsdefaultValuestring[]The initial default value of the select when rendered. Use when you don't need to control the value of the select.highlightedValuestringThe controlled key of the highlighted itemdefaultHighlightedValuestringThe initial value of the highlighted item when opened. Use when you don't need to control the highlighted value of the select.loopFocusbooleanWhether to loop the keyboard navigation through the optionsmultiplebooleanWhether to allow multiple selectionopenbooleanWhether the select menu is opendefaultOpenbooleanWhether the select's open state is controlled by the userscrollToIndexFn(details: ScrollToIndexDetails) => voidFunction to scroll to a specific indexcompositebooleanWhether the select is a composed with other composite widgets like tabs or comboboxdeselectablebooleanWhether the value can be cleared by clicking the selected item. **Note:** this is only applicable for single selectiondir"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 select api exposes the following methods:
focusedbooleanWhether the select is focusedopenbooleanWhether the select is openemptybooleanWhether the select value is emptyhighlightedValuestringThe value of the highlighted itemhighlightedItemVThe highlighted itemsetHighlightValue(value: string) => voidFunction to highlight a valueclearHighlightValueVoidFunctionFunction to clear the highlighted valueselectedItemsV[]The selected itemshasSelectedItemsbooleanWhether there's a selected optionvaluestring[]The selected item keysvalueAsStringstringThe string representation of the selected itemsselectValue(value: string) => voidFunction to select a valueselectAllVoidFunctionFunction to select all valuessetValue(value: string[]) => voidFunction to set the value of the selectclearValue(value?: string) => voidFunction to clear the value of the select. If a value is provided, it will only clear that value, otherwise, it will clear all values.focusVoidFunctionFunction to focus on the select inputgetItemState(props: ItemProps<any>) => ItemStateReturns the state of a select itemsetOpen(open: boolean) => voidFunction to open or close the selectcollectionListCollection<V>Function to toggle the selectreposition(options?: Partial<PositioningOptions>) => voidFunction to set the positioning options of the selectmultiplebooleanWhether the select allows multiple selectionsdisabledbooleanWhether the select is disabled
Data Attributes
CSS Variables
Accessibility
Adheres to the ListBox WAI-ARIA design pattern.
Keyboard Interactions
- SpaceWhen focus is on trigger, opens the select and focuses the first selected item.
When focus is on the content, selects the highlighted item. - EnterWhen focus is on trigger, opens the select and focuses the first selected item.
When focus is on content, selects the focused item. - ArrowDownWhen focus is on trigger, opens the select.
When focus is on content, moves focus to the next item. - ArrowUpWhen focus is on trigger, opens the select.
When focus is on content, moves focus to the previous item. - EscCloses the select and moves focus to trigger.
- A-Za-zWhen focus is on trigger, selects the item whose label starts with the typed character.
When focus is on the listbox, moves focus to the next item with a label that starts with the typed character.