A select component lets you pick a value from predefined options. It supports single and multiple selection, typeahead, keyboard navigation, RTL, and form submission.
Features
- Single and multiple selection
- Typeahead and full keyboard navigation
- RTL support
- Controlled open state, value, and highlighted item
- Form submission and browser autofill via hidden
<select>
- Custom object formats via collection adapters
- Virtualized lists via
scrollToIndexFn
Installation
npm install @zag-js/select @zag-js/react
Usage
import { useMachine, normalizeProps } from "@zag-js/react"
import * as select from "@zag-js/select"
import { useId } from "react"
const countries = [
{ label: "Nigeria", value: "ng" },
{ label: "Ghana", value: "gh" },
{ label: "Kenya", value: "ke" },
]
const collection = select.collection({ items: countries })
export function Select() {
const service = useMachine(select.machine, {
id: useId(),
collection,
})
const api = select.connect(service, normalizeProps)
return (
<div {...api.getRootProps()}>
<label {...api.getLabelProps()}>Country</label>
<div {...api.getControlProps()}>
<button {...api.getTriggerProps()}>
<span {...api.getValueTextProps()}>
{api.empty ? "Select a country" : api.valueAsString}
</span>
</button>
</div>
<div {...api.getPositionerProps()}>
<ul {...api.getContentProps()}>
{countries.map((item) => (
<li key={item.value} {...api.getItemProps({ item })}>
<span {...api.getItemTextProps({ item })}>{item.label}</span>
<span {...api.getItemIndicatorProps({ item })}>✓</span>
</li>
))}
</ul>
</div>
<select {...api.getHiddenSelectProps()} />
</div>
)
}
<script setup>
import { useMachine, normalizeProps } from "@zag-js/vue"
import * as select from "@zag-js/select"
import { useId, computed } from "vue"
const countries = [
{ label: "Nigeria", value: "ng" },
{ label: "Ghana", value: "gh" },
{ label: "Kenya", value: "ke" },
]
const collection = select.collection({ items: countries })
const service = useMachine(select.machine, {
id: useId(),
collection,
})
const api = computed(() => select.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">Country</label>
<div v-bind="api.getControlProps()">
<button v-bind="api.getTriggerProps()">
<span v-bind="api.getValueTextProps()">
{{ api.empty ? "Select a country" : api.valueAsString }}
</span>
</button>
</div>
<div v-bind="api.getPositionerProps()">
<ul v-bind="api.getContentProps()">
<li
v-for="item in countries"
:key="item.value"
v-bind="api.getItemProps({ item })"
>
<span v-bind="api.getItemTextProps({ item })">{{ item.label }}</span>
<span v-bind="api.getItemIndicatorProps({ item })">✓</span>
</li>
</ul>
</div>
<select v-bind="api.getHiddenSelectProps()" />
</div>
</template>
Setting the initial value
Pass defaultValue as an array of string values. Even for single selection, use an array.
const service = useMachine(select.machine, {
id: useId(),
collection,
defaultValue: ["ng"],
})
Multiple selection
Set multiple: true to allow selecting more than one item.
const service = useMachine(select.machine, {
id: useId(),
collection,
multiple: true,
})
Controlled value
Use value and onValueChange for controlled selection state.
const service = useMachine(select.machine, {
id: useId(),
collection,
value,
onValueChange(details) {
// details => { value: string[], items: Item[] }
setValue(details.value)
},
})
Custom object format
Pass itemToString, itemToValue, and itemToDisabled to the collection function when your items don’t have label/value properties.
const collection = select.collection({
items: [
{ id: 1, fruit: "Banana", available: true },
{ id: 2, fruit: "Apple", available: false },
],
itemToString: (item) => item.fruit,
itemToValue: (item) => String(item.id),
itemToDisabled: (item) => !item.available,
})
Grouping items
Use groupBy on the collection and collection.group() when rendering.
const collection = select.collection({
items,
itemToValue: (item) => item.value,
itemToString: (item) => item.label,
groupBy: (item) => item.category,
})
// Rendering grouped items
collection.group().map(([group, items]) => (
<div key={group}>
<div {...api.getItemGroupProps({ id: group })}>{group}</div>
{items.map((item) => (
<div key={item.value} {...api.getItemProps({ item })}>
<span {...api.getItemTextProps({ item })}>{item.label}</span>
<span {...api.getItemIndicatorProps({ item })}>✓</span>
</div>
))}
</div>
))
Usage within a form
Set name and render the hidden select to support form submission and browser autofill.
const service = useMachine(select.machine, {
id: useId(),
collection,
name: "country",
autoComplete: "country",
})
// In JSX
<select {...api.getHiddenSelectProps()} />
Controlling open state
Use open/onOpenChange for controlled open state, or defaultOpen for initial state.
const service = useMachine(select.machine, {
id: useId(),
collection,
open,
onOpenChange(details) {
setOpen(details.open)
},
})
Allowing deselection
Set deselectable to allow clicking the selected item to clear the value (single selection only).
const service = useMachine(select.machine, {
id: useId(),
collection,
deselectable: true,
})
API reference
The item collection created with select.collection(...). Required.
The initial selected values (uncontrolled).
The controlled selected values. Use with onValueChange.
Whether to allow multiple items to be selected.
Whether clicking the selected item clears the value (single selection only).
Whether the dropdown closes when an item is selected. Defaults to true.
Whether keyboard navigation wraps from the last to first item. Defaults to false.
The controlled open state of the dropdown.
The initial open state (uncontrolled).
The controlled highlighted item value.
Whether the select is disabled.
Whether the select is in an invalid state.
Whether the select is read-only.
The name attribute for form submission.
The autocomplete attribute on the hidden select for browser autofill.
Positioning options for the dropdown (placement, offset, etc.).
scrollToIndexFn
(details: ScrollToIndexDetails) => void
A function to scroll to a specific index. Use with virtualization libraries.
onValueChange
(details: { value: string[], items: Item[] }) => void
Callback fired when the selection changes.
onHighlightChange
(details: HighlightChangeDetails) => void
Callback fired when the highlighted item changes.
onOpenChange
(details: { open: boolean, value: string[] }) => void
Callback fired when the dropdown opens or closes.
Styling
Each part has a data-part attribute for CSS targeting.
Open and closed state
[data-part="trigger"][data-state="open"] { /* trigger when open */ }
[data-part="trigger"][data-state="closed"] { /* trigger when closed */ }
[data-part="content"][data-state="open"] { /* content when open */ }
[data-part="content"][data-state="closed"] { /* content when closed */ }
Selected and highlighted items
[data-part="item"][data-state="checked"] { /* selected item */ }
[data-part="item"][data-state="unchecked"] { /* unselected item */ }
[data-part="item"][data-highlighted] { /* keyboard/pointer highlighted item */ }
Disabled and invalid states
[data-part="trigger"][data-disabled] { /* disabled trigger */ }
[data-part="label"][data-disabled] { /* disabled label */ }
[data-part="item"][data-disabled] { /* disabled item */ }
[data-part="trigger"][data-invalid] { /* invalid trigger */ }
[data-part="label"][data-invalid] { /* invalid label */ }
Empty (placeholder) state
[data-part="trigger"][data-placeholder-shown] {
/* styles when no value is selected */
}
Accessibility
Adheres to the ListBox WAI-ARIA design pattern.
Keyboard interactions
| Key | Description |
|---|
Space / Enter | Opens the dropdown or selects the highlighted item |
ArrowDown | Highlights the next item; opens dropdown if closed |
ArrowUp | Highlights the previous item; opens dropdown if closed |
Home | Highlights the first item |
End | Highlights the last item |
Escape | Closes the dropdown without changing selection |
A–Z, a–z | Typeahead to jump to matching item |