Select
A Select component allows users pick a value from predefined options.
- Nigeria
- Japan
- Korea
- Kenya
- United Kingdom
Features
- Support for selecting a single or multiple option
- Keyboard support for opening the listbox using the arrow keys, including automatically focusing the first or last item.
- Support for looping keyboard navigation.
- Support for selecting an item on blur.
- Typeahead to allow selecting options by typing text, even without opening the listbox
- Support for Right to Left direction.
Installation
To use the select machine in your project, run the following command in your command line:
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/vue # or yarn add @zag-js/select @zag-js/vue
This command will install the framework agnostic menu logic and the reactive utilities for your framework of choice.
Anatomy
To set up the select correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-part
attribute to help identify them in the DOM.
Usage
First, import the select package into your project
import * as select from "@zag-js/select"
The select package exports these functions:
machine
— The state machine logic for the select.connect
— The function that translates the machine's state to JSX attributes and event handlers.collection
- The function that creates a collection interface from an array of items.
You'll need to provide a unique
id
to theuseMachine
hook. This is used to ensure that every part has a unique identifier.
Next, import the required hooks and functions for your framework and use the select machine in your project 🔥
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 [state, send] = useMachine( select.machine({ id: useId(), collection, }), ) const api = select.connect(state, send, normalizeProps) return ( <div {...api.rootProps}> <div {...api.controlProps}> <label {...api.labelProps}>Label</label> <button {...api.triggerProps}> {api.valueAsString || "Select option"} </button> </div> <Portal> <div {...api.positionerProps}> <ul {...api.contentProps}> {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 [state, send] = useMachine( select.machine({ id: createUniqueId(), collection: select.collection({ items: selectData, }), }), ) const api = createMemo(() => select.connect(state, send, normalizeProps)) return ( <div> <div> <label {...api().labelProps}>Label</label> <button {...api().triggerProps}> <span>{api().valueAsString || "Select option"}</span> </button> </div> <div {...api().positionerProps}> <ul {...api().contentProps}> {selectData.map((item) => ( <li key={item.value} {...api().getItemProps({ item })}> <span>{item.label}</span> <span {...api().getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </div> ) }
import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, defineComponent, h, Fragment, Teleport } from "vue" const selectData = [ { label: "Nigeria", value: "NG" }, { label: "Japan", value: "JP" }, //... ] export default defineComponent({ name: "Select", setup() { const [state, send] = useMachine( select.machine({ id: "1", collection: select.collection({ items: selectData, }), }), ) const apiRef = computed(() => select.connect(state.value, send, normalizeProps), ) return () => { const api = apiRef.value return ( <> <div> <label {...api.labelProps}>Label</label> <button {...api.triggerProps}> <span>{api.valueAsString || "Select option"}</span> </button> </div> <Teleport to="body"> <div {...api.positionerProps}> <ul {...api.contentProps}> {selectData.map((item) => ( <li key={item.value} {...api.getItemProps({ item })}> <span>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </Teleport> </> ) } }, })
<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 [state, send] = useMachine( select.machine({ id: "1", collection: select.collection({ items: selectData, }), }) ) const api = computed(() => select.connect(state.value, send, normalizeProps)) </script> <template> <div> <label v-bind="api.labelProps">Label</label> <button v-bind="api.triggerProps"> <span>{{ api.valueAsString || "Select option" }}</span> <span>▼</span> </button> </div> <Teleport to="body"> <div v-bind="api.positionerProps"> <ul v-bind="api.contentProps"> <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>
Setting the initial value
To set the initial value of the select, pass the value
property to the select
machine's context.
The
value
property 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 [state, send] = useMachine( select.machine({ id: useId(), collection, value: ["ng"], }), )
Selecting multiple values
To allow selecting multiple values, set the multiple
property in the machine's
context to true
.
const [state, send] = useMachine( select.machine({ id: useId(), collection, multiple: true, }), )
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.
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 }, }) // use the collection const [state, send] = useMachine( select.machine({ id: useId(), collection, }), )
Usage within a form
To use select within a form, you'll need to:
- Pass the
name
property to the select machine's context - Render a hidden
select
element usingapi.selectProps
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 [state, send] = useMachine( select.machine({ id: useId(), collection: select.collection({ items: selectData }) name: "country", }), ) const api = select.connect(state, send, normalizeProps) return ( <form> {/* Hidden select */} <select {...api.hiddenSelectProps}> {selectData.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {/* Custom Select */} <div {...api.controlProps}> <label {...api.labelProps}>Label</label> <button type="button" {...api.triggerProps}> <span>{api.valueAsString || "Select option"}</span> <CaretIcon /> </button> </div> <Portal> <div {...api.positionerProps}> <ul {...api.contentProps}> {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 [state, send] = useMachine( select.machine({ collection: select.collection({ items: selectData, }), id: createUniqueId(), name: "country", }), )Ø const api = createMemo(() => select.connect(state, send, normalizeProps)) return ( <form> <div {...api.rootProps}> {/* Hidden select */} <select {...api().hiddenSelectProps}> {selectData.map((item) => ( <option key={item.value} value={item.value}> {item.label} </option> ))} </select> {/* Custom Select */} <div {...api.controlProps}> <label {...api().labelProps}>Label</label> <button type="button" {...api().triggerProps}> <span>{api().valueAsString || "Select option"}</span> <CaretIcon /> </button> </div> <Portal> <div {...api().positionerProps}> <ul {...api().contentProps}> {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> ) }
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" }, //... ] export default defineComponent({ name: "Select", setup() { const [state, send] = useMachine( select.machine({ id: "1", collection: select.collection({ items: selectData, }), name: "country", }), ) const apiRef = computed(() => select.connect(state.value, send, normalizeProps), ) return () => { const api = apiRef.value return ( <form> {/* Hidden select */} <select {...api.hiddenSelectProps}> {selectData.map((item) => ( <option key={item.value} value={item.value}> {item.label} </option> ))} </select> {/* Custom Select */} <div {...api.controlProps}> <label {...api.labelProps}>Label</label> <button type="button" {...api.triggerProps}> <span>{api.valueAsString || "Select option"}</span> <CaretIcon /> </button> </div> <Teleport to="body"> <div {...api.positionerProps}> <ul {...api.contentProps}> {selectData.map((item) => ( <li key={item.value} {...api.getItemProps({ item })}> <span>{item.label}</span> <span {...api.getItemIndicatorProps({ item })}>✓</span> </li> ))} </ul> </div> </Teleport> </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 [state, send] = useMachine( select.machine({ id: "1", collection: select.collection({ items: selectData, }), name: "country", }), ) const api = computed(() => select.connect(state.value, send, normalizeProps)) </script> <template> <form> <!-- Hidden select --> <select v-bind="api.hiddenSelectProps"> <option v-for="item in selectData" :key="item.value" :value="item.value"> {{ item.label }} </option> </select> <!-- Custom Select --> <div v-bind="api.controlProps"> <label v-bind="api.labelProps">Label</label> <button type="button" v-bind="api.triggerProps"> <span>{{ api.valueAsString || "Select option" }}</span> <span>▼</span> </button> </div> <Teleport to="body"> <div v-bind="api.positionerProps"> <ul v-bind="api.contentProps"> <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>
Selecting option on blur
Use the selectOnBlur
property to allow selecting the highlighted option when
immediately interacting or focusing outside the select (e.g. using the Tab
key).
const [state, send] = useMachine( select.machine({ id: useId(), collection, selectOnBlur: true, }), )
Disabling the select
To disable the select, set the disabled
property in the machine's context to
true
.
const [state, send] = useMachine( select.machine({ id: useId(), collection, disabled: true, }), )
Disabling an item
To make a combobox option disabled, pass the isItemDisabled
property to the
collection function.
const collection = select.collection({ items: countries, isItemDisabled(item) { return item.disabled }, }) const [state, send] = useMachine( select.machine({ id: useId(), collection, }), )
Close on select
This behaviour ensures that the menu is closed when an item is selected and is
true
by default. It's only concerned with when an item is selected with
pointer, space key or enter key. To disable the behaviour, set the
closeOnSelect
property in the machine's context to false
.
const [state, send] = useMachine( select.machine({ id: useId(), collection, closeOnSelect: false, }), )
Looping the keyboard navigation
When navigating with the select using the arrow down and up keys, the select
stops at the first and last options. If you need want the navigation to loop
back to the first or last option, set the loop: true
in the machine's context.
const [state, send] = useMachine( select.machine({ id: useId(), collection, loop: 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 [state, send] = useMachine( select.machine({ id: useId(), onHighlightChange(details) { // details => { highlightedValue: string | null, highlightedItem: CollectionItem | null } 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 [state, send] = useMachine( select.machine({ id: useId(), collection, onValueChange(details) { // details => { value: string[], items: Item[] } console.log(details) }, }), )
Listening for open and close events
When the select is opened or closed, the onOpenChange
callback is called. You
can listen for these events and do something with it.
const [state, send] = useMachine( select.machine({ id: useId(), collection, onOpenChange(details) { // details => { open: boolean } console.log("Select opened") }, }), )
Usage with large data
Combine the select machine with the virtualization library like react-window
or @tanstack/react-virtual
to handle large data.
Here's an example using @tanstack/react-virtual
:
function Demo() { const selectData = [] const contentRef = useRef(null) const rowVirtualizer = useVirtualizer({ count: selectData.length, getScrollElement: () => contentRef.current, estimateSize: () => 32, }) const [state, send] = useMachine( select.machine({ id: useId(), collection, scrollToIndexFn(details) { rowVirtualizer.scrollToIndex(details.index, { align: "center", behavior: "auto", }) }, }), ) const api = select.connect(state, send, normalizeProps) return ( <div {...api.rootProps}> {/* ... */} <Portal> <div {...api.positionerProps}> <div ref={contentRef} {...api.contentProps}> <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 the select within a dialog, you'll need to avoid rendering the select
in a Portal
or Teleport
. This is because the dialog will trap focus within
it, and the select will be rendered outside the dialog.
Consider designing a
portalled
property in your component to allow you decide where to render the select in a portal.
Styling guide
Earlier, we mentioned that each select part has a data-part
attribute added to
them to select and style them in the DOM.
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-disabled
attribute.
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:
collection
Collection<any>
The item collectionids
Partial<{ 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.name
string
The `name` attribute of the underlying select.form
string
The associate form of the underlying select.disabled
boolean
Whether the select is disabledinvalid
boolean
Whether the select is invalidreadOnly
boolean
Whether the select is read-onlycloseOnSelect
boolean
Whether the select should close after an item is selectedselectOnBlur
boolean
Whether to select the highlighted item when the user presses Tab, and the menu is open.onHighlightChange
(details: HighlightChangeDetails<T>) => void
The callback fired when the highlighted item changes.onValueChange
(details: ValueChangeDetails<T>) => void
The callback fired when the selected item changes.onOpenChange
(details: OpenChangeDetails) => void
Function called when the popup is openedpositioning
PositioningOptions
The positioning options of the menu.value
string[]
The keys of the selected itemshighlightedValue
string
The key of the highlighted itemloop
boolean
Whether to loop the keyboard navigation through the optionsmultiple
boolean
Whether to allow multiple selectionopen
boolean
Whether the select menu is openopen.controlled
boolean
Whether the select's open state is controlled by the userscrollToIndexFn
(details: ScrollToIndexDetails) => void
Function to scroll to a specific indexdir
"ltr" | "rtl"
The document's text/writing direction.id
string
The unique identifier of the machine.getRootNode
() => ShadowRoot | Node | Document
A root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.onPointerDownOutside
(event: PointerDownOutsideEvent) => void
Function called when the pointer is pressed down outside the componentonFocusOutside
(event: FocusOutsideEvent) => void
Function called when the focus is moved outside the componentonInteractOutside
(event: InteractOutsideEvent) => void
Function called when an interaction happens outside the component
Machine API
The select api
exposes the following methods:
isFocused
boolean
Whether the select is focusedisOpen
boolean
Whether the select is openisValueEmpty
boolean
Whether the select value is emptyhighlightedValue
string
The value of the highlighted itemhighlightedItem
V
The highlighted itemhighlightValue
(value: string) => void
The value of the select inputselectedItems
V[]
The selected itemshasSelectedItems
boolean
Whether there's a selected optionvalue
string[]
The selected item keysvalueAsString
string
The string representation of the selected itemsselectValue
(value: string) => void
Function to select a valuesetValue
(value: string[]) => void
Function to set the value of the selectclearValue
(value?: string) => void
Function to clear the value of the selectfocus
() => void
Function to focus on the select inputgetItemState
(props: ItemProps<any>) => ItemState
Returns the state of a select itemopen
() => void
Function to open the selectclose
() => void
Function to close the selectcollection
Collection<V>
Function to toggle the selectsetCollection
(collection: Collection<V>) => void
Function to set the collection of itemsreposition
(options: Partial<PositioningOptions>) => void
Function to set the positioning options of the select
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.
Edit this page on GitHub