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 } 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" import { Portal } from "solid-js/web" 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:
translationsIntlTranslations | undefinedSpecifies the localized strings that identifies the accessibility elements and their statescollectionListCollection<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; }> | undefinedThe ids of the elements in the select. Useful for composition.namestring | undefinedThe `name` attribute of the underlying select.formstring | undefinedThe associate form of the underlying select.autoCompletestring | undefinedThe autocomplete attribute for the hidden select. Enables browser autofill (e.g. "address-level1" for state).disabledboolean | undefinedWhether the select is disabledinvalidboolean | undefinedWhether the select is invalidreadOnlyboolean | undefinedWhether the select is read-onlyrequiredboolean | undefinedWhether the select is requiredcloseOnSelectboolean | undefinedWhether the select should close after an item is selectedonSelect((details: SelectionDetails) => void) | undefinedFunction called when an item is selectedonHighlightChange((details: HighlightChangeDetails<T>) => void) | undefinedThe callback fired when the highlighted item changes.onValueChange((details: ValueChangeDetails<T>) => void) | undefinedThe callback fired when the selected item changes.onOpenChange((details: OpenChangeDetails) => void) | undefinedFunction called when the popup is openedpositioninganyThe positioning options of the menu.valuestring[] | undefinedThe controlled keys of the selected itemsdefaultValuestring[] | undefinedThe initial default value of the select when rendered. Use when you don't need to control the value of the select.highlightedValuestring | null | undefinedThe controlled key of the highlighted itemdefaultHighlightedValuestring | null | undefinedThe initial value of the highlighted item when opened. Use when you don't need to control the highlighted value of the select.loopFocusboolean | undefinedWhether to loop the keyboard navigation through the optionsmultipleboolean | undefinedWhether to allow multiple selectionopenboolean | undefinedWhether the select menu is opendefaultOpenboolean | undefinedWhether the select's open state is controlled by the userscrollToIndexFn((details: ScrollToIndexDetails) => void) | undefinedFunction to scroll to a specific indexcompositeboolean | undefinedWhether the select is a composed with other composite widgets like tabs or comboboxdeselectableboolean | undefinedWhether the value can be cleared by clicking the selected item. **Note:** this is only applicable for single selectiondir"ltr" | "rtl" | undefinedThe document's text/writing direction.idstringThe unique identifier of the machine.getRootNode(() => ShadowRoot | Node | Document) | undefinedA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.onPointerDownOutside((event: PointerDownOutsideEvent) => void) | undefinedFunction called when the pointer is pressed down outside the componentonFocusOutside((event: FocusOutsideEvent) => void) | undefinedFunction called when the focus is moved outside the componentonInteractOutside((event: InteractOutsideEvent) => void) | undefinedFunction 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 emptyhighlightedValuestring | nullThe value of the highlighted itemhighlightedItemV | nullThe 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 | undefined) => 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<CollectionItem>) => ItemStateReturns the state of a select itemsetOpen(open: boolean) => voidFunction to open or close the selectcollectionListCollection<V>Function to toggle the selectreposition(options?: any) => 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.