Select
A Select component allows users pick a value from predefined options.
Features
- Support for selecting a single 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 option with tab key.
- 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/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
npm install @zag-js/select @zag-js/solid # or yarn add @zag-js/select @zag-js/solid
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.
On a high level, the select consists of:
- Label: The element that labels the select.
- Trigger: The element that is used to open/close the select.
- Positioner: The element that positions the select dynamically.
- Menu: The popup element that contains the options.
- Option: The option element.
- OptionGroup: The element used to group options.
Usage
First, import the select package into your project
import * as select from "@zag-js/select"
The select package exports two key functions:
machine
— The state machine logic for the select.connect
— The function that translates the machine's state to JSX attributes and event handlers.
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" export function Select() { const [state, send] = useMachine( select.machine({ id: useId(), }), ) const api = select.connect(state, send, normalizeProps) return ( <> <div> <label {...api.labelProps}>Label</label> <button {...api.triggerProps}> <span>{api.selectedOption?.label ?? "Select option"}</span> </button> </div> <Portal> <div {...api.positionerProps}> <ul {...api.contentProps}> {selectData.map(({ label, value }) => ( <li key={value} {...api.getOptionProps({ label, value })}> <span>{label}</span> {value === api.selectedOption?.value && "✓"} </li> ))} </ul> </div> </Portal> </> ) }
import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed, defineComponent, h, Fragment, Teleport } from "vue" export default defineComponent({ name: "Select", setup() { const [state, send] = useMachine(select.machine({ id: "1" })) 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.selectedOption?.label ?? "Select option"}</span> </button> </div> <Teleport to="body"> <div {...api.positionerProps}> <ul {...api.contentProps}> {selectData.map(({ label, value }) => ( <li key={value} {...api.getOptionProps({ label, value })}> <span>{label}</span> {value === api.selectedOption?.value && "✓"} </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 [state, send] = useMachine(select.machine({ id: "1" })) 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.selectedOption?.label ?? "Select option" }}</span> </button> </div> <Teleport to="body"> <div v-bind="api.positionerProps"> <ul v-bind="api.contentProps"> <li v-for="{label, value} in selectData" :key="value" v-bind="api.getOptionProps({ label, value })" > <span>{{ label }}</span> {{ value === api.selectedOption?.value && "✓" }} </li> </ul> </div> </Teleport> </template>
import * as select from "@zag-js/select" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId } from "solid-js" export function Select() { const [state, send] = useMachine(menu.machine({ id: createUniqueId() })) const api = createMemo(() => select.connect(state, send, normalizeProps)) return ( <div> <div> <label {...api().labelProps}>Label</label> <button {...api().triggerProps}> <span>{api().selectedOption?.label ?? "Select option"}</span> </button> </div> <div {...api().positionerProps}> <ul {...api().contentProps}> {selectData.map(({ label, value }) => ( <li key={value} {...api().getOptionProps({ label, value })}> <span>{label}</span> {value === api().selectedOption?.value && "✓"} </li> ))} </ul> </div> </div> ) }
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(), name: "country", }), ) const api = select.connect(state, send, normalizeProps) return ( <div> {/* Hidden select */} <select {...api.hiddenSelectProps}> {selectData.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {/* Custom Select */} <div className="control"> <label {...api.labelProps}>Label</label> <button type="button" {...api.triggerProps}> <span>{api.selectedOption?.label ?? "Select option"}</span> <CaretIcon /> </button> </div> <Portal> <div {...api.positionerProps}> <ul {...api.contentProps}> {selectData.map(({ label, value }) => ( <li key={value} {...api.getOptionProps({ label, value })}> <span>{label}</span> {value === api.selectedOption?.value && "✓"} </li> ))} </ul> </div> </Portal> </div> ) }
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" }, { label: "Korea", value: "KO" }, { label: "Kenya", value: "KE" }, { label: "United Kingdom", value: "UK" }, { label: "Ghana", value: "GH" }, { label: "Uganda", value: "UG" }, ] export default defineComponent({ name: "Select", setup() { const [state, send] = useMachine( select.machine({ id: "1", name: "country", }), ) const apiRef = computed(() => select.connect(state.value, send, normalizeProps), ) return () => { const api = apiRef.value return ( <div> {/* Hidden select */} <select {...api.hiddenSelectProps}> {selectData.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {/* Custom Select */} <div class="control"> <label {...api.labelProps}>Label</label> <button type="button" {...api.triggerProps}> <span>{api.selectedOption?.label ?? "Select option"}</span> <CaretIcon /> </button> </div> <Teleport to="body"> <div {...api.positionerProps}> <ul {...api.contentProps}> {selectData.map(({ label, value }) => ( <li key={value} {...api.getOptionProps({ label, value })}> <span>{label}</span> {value === api.selectedOption?.value && "✓"} </li> ))} </ul> </div> </Teleport> </div> ) } }, })
<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" }, { label: "Korea", value: "KO" }, { label: "Kenya", value: "KE" }, { label: "United Kingdom", value: "UK" }, { label: "Ghana", value: "GH" }, { label: "Uganda", value: "UG" }, ] const [state, send] = useMachine( select.machine({ id: "1", name: "country", }), ) const api = computed(() => select.connect(state.value, send, normalizeProps), ) </script> <template> <div> <!-- Hidden select --> <select v-bind="api.hiddenSelectProps"> <option v-for="option in selectData" :key="option.value" :value="option.value" > {{option.label}} </option> </select> <!-- Custom Select --> <div class="control"> <label v-bind="api.labelProps">Label</label> <button type="button" v-bind="api.triggerProps"> <span>{{api.selectedOption?.label ?? "Select option"}}</span> <CaretIcon /> </button> </div> <Teleport to="body"> <div v-bind="api.positionerProps"> <ul v-bind="api.contentProps"> <li v-for="{label, value} in selectData" :key="value" v-bind="api.getOptionProps({ label, value })" > <span>{{label}}</span> {{value === api.selectedOption?.value && "✓"}} </li> </ul> </div> </Teleport> </div> </template>
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" }, { 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: createUniqueId(), name: "country" }), ) const api = createMemo(() => select.connect(state, send, normalizeProps)) return ( <div> <div className="select"> {/* Hidden select */} <select {...api().hiddenSelectProps}> {selectData.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> {/* Custom Select */} <div className="control"> <label {...api().labelProps}>Label</label> <button type="button" {...api().triggerProps}> <span>{api().selectedOption?.label ?? "Select option"}</span> <CaretIcon /> </button> </div> <Portal> <div {...api().positionerProps}> <ul {...api().contentProps}> {selectData.map(({ label, value }) => ( <li key={value} {...api().getOptionProps({ label, value })}> <span>{label}</span> {value === api().selectedOption?.value && "✓"} </li> ))} </ul> </div> </Portal> </div> </div> ) }
Selecting option on tab key
While navigating the options, pressing the Tab
key blurs the select and does
nothing. In some cases, you might what the Tab
key to select the currently
highlighted option. To enable this behaviour, set selectOnTab
to true
.
const [state, send] = useMachine( select.machine({ id: useId(), selectOnTab: 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(), disabled: true, }), )
Disabling an option
To disable an option, pass the disabled
property in the
api.getOptionProps(...)
function. This will make it unselectable via mouse and
keyboard, and it will be skipped on keyboard navigation.
//... <li {...api.getOptionProps({ label, value, disabled: true })}> {option.label} </li> //...
Close on select
This behaviour ensures that the menu is closed when an option is selected and is
true
by default. It's only concerned with when an option 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(), 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(), loop: true, }), )
Listening for highlight changes
When an option is highlighted with the pointer or keyboard, the
highlightedOption
property in the machine's context is updated. You can listen
for this change and do something with it.
const [state, send] = useMachine( select.machine({ id: useId(), onHighlight(details) { // details => { label: string, value: string } console.log(details) }, }), )
Listening for selection changes
When an option is selected, the selectedOption
property in the machine's
context is updated. You can listen for this change and do something with it.
const [state, send] = useMachine( select.machine({ id: useId(), onSelect(details) { // details => { label: string, value: string } console.log(details) }, }), )
Listening for open and close events
When the select is opened or closed, the onOpen
and onClose
properties in
the machine's context are called. You can listen for these events and do
something with it.
const [state, send] = useMachine( select.machine({ id: useId(), onOpen() { console.log("Select opened") }, onClose() { console.log("Select closed") }, }), )
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
When an option is selected, it is given a data-selected
attribute.
[data-part="option"][data-selected] { /* styles for selected state */ }
Highlighted state
When an option is higlighted, via keyboard navigation or pointer, it is given a
data-focus
attribute.
[data-part="option"][data-focus] { /* 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="option"][data-disabled] { /* styles for disabled option state */ }
Optionally, when an option 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
The select's api
exposes the following methods:
isOpen
boolean
Whether the select is openhighlightedOption
Option
The currently highlighted optionselectedOption
Option
The currently selected optionfocus
() => void
Function to focus the selectopen
() => void
Function to open the selectclose
() => void
Function to close the selectsetSelectedOption
(value: Option) => void
Function to set the selected optionsetHighlightedOption
(value: Option) => void
Function to set the highlighted optionclearSelectedOption
() => void
Function to clear the selected optiongetOptionState
(props: OptionProps) => { isDisabled: boolean; isHighlighted: boolean; isChecked: boolean; }
Returns the state details of an option
Edit this page on GitHub