Skip to content

Select

Select is a custom select: a button that shows the chosen option and opens a listbox, following the WAI-ARIA select-only combobox pattern. Keyboard, focus and typeahead come from Zag.js’s select machine, rendered with fromZag. It is a widget tag.

A native <select> is smaller and works everywhere; use Select when the options need custom styling, or several values with checkmarks.

import { Select } from 'sygnal/ui/select'
const SIZES = [
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large', disabled: true },
]
function Order({ state }) {
return (
<form className="order">
<Select className="size" label="Size" items={SIZES} value={state.size} placeholder="Pick a size" name="size" />
<p>{state.size ? `Size: ${state.size}` : 'No size yet'}</p>
</form>
)
}
Order.initialState = { size: null }
Order.intent = ({ DOM }) => ({
SIZE: DOM.select('.size').events('value-change').detail(),
})
Order.model = {
SIZE: (state, size) => ({ ...state, size }),
}

The value is controlled: the select shows state.size, and value-change reports the user’s choice. Leave value out (or use defaultValue) to let the select keep its own value.

Install Zag’s packages first: npm install @zag-js/vanilla@~1.45.0 @zag-js/select@~1.45.0 (see Menu).

Prop
labelThe visible label, linked to the trigger (its accessible name). Give every select one
aria-label, aria-labelledby, aria-describedbyName or describe the trigger when there is no visible label (or in addition to it); they go on the trigger, not the host
items(required)Strings, or { value, label, disabled } objects. Values are strings
valueControlled: the value (an array with multiple); null for none
defaultValueThe start value when value is left out
placeholderShown while nothing is selected
multiplefalseSeveral values; the list stays open while picking
name, formThe form field: a hidden native <select> carries the value in FormData
disabled, readOnly, required, invalid
deselectablefalseA click on the selected option clears it
closeOnSelecttrueWith multiple: false
loopFocusfalse
positioningZag’s positioning options: { placement: 'bottom-start', sameWidth: true }
openControlled open state; follow open-change
EventDetail
value-changeThe value (with multiple: the array of values); null when cleared
open-changetrue / false

Commands for ELEMENT: open, close, clear, and focus (the trigger): CLEAR_SIZE: { ELEMENT: { clear: '.size' } }.

Key
Enter, Space, Down, UpOn the trigger: open the list
A letterOn the trigger: select the next option starting with it; in the list: move to it
Down / Up, Home / EndMove in the list; disabled options are skipped
EnterSelect the highlighted option and close; the focus returns to the trigger
Escape, TabClose

Zag’s data attributes: [data-scope="select"] with [data-part="root"], "label", "control", "trigger", "value-text", "indicator", "positioner", "content", "item", "item-text" and "item-indicator". The trigger and content have data-state, options data-highlighted, data-state="checked" and data-disabled, and the value text data-placeholder-shown while empty. The item indicator (✓) is hidden on unselected options.

.size [data-part='trigger'] { min-width: 10rem; display: flex; justify-content: space-between; }
.size [data-part='content'] { background: Canvas; border: 1px solid #ddd; }
.size [data-part='item'] { padding: 4px 8px; }
.size [data-part='item'][data-highlighted] { background: rgb(99 102 241 / 0.25); }
.size [data-part='item'][data-disabled] { opacity: 0.5; }

The listbox renders inside the widget’s host, next to the trigger, and Zag positions it with position: absolute (the --x / --y variables on the positioner). Inside a container that clips its content (overflow: hidden or auto: a card, a scrolling panel, a table cell), the open listbox is cut off at the container’s edge. Pass positioning={{ strategy: 'fixed' }} there: the positioner is then placed relative to the viewport and escapes the clipping (it still follows the trigger when the page scrolls; an ancestor with a transform, filter or contain still clips it). Other options go in the same object: positioning={{ placement: 'bottom-end', gutter: 4, strategy: 'fixed' }}.

import { it, expect } from 'vitest'
import { renderComponent } from 'sygnal'
import Order from './Order.jsx'
it('keeps the chosen size', async () => {
const t = renderComponent(Order)
await t.ready()
t.widget('.size').dispatch('value-change', 'm')
await t.next((state) => state.size === 'm')
expect(t.widget('.size').props.value).toBe('m')
t.dispose()
})

For keyboard tests, see Menu.

Gzipped, in a small app: Select adds 33 KB (mostly Zag’s select machine, its positioning and @zag-js/vanilla). With Menu and Combobox, the three add 47 KB together.