Select
Choose one or more values from a dropdown list.
Codeapp/demos/public/select.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { Button } from '@stemsui/registry/ui/button.tsx'
import { Label } from '@stemsui/registry/ui/label.tsx'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@stemsui/registry/ui/select.tsx'
const produce = [
{
label: 'Fruits',
items: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'blueberry', label: 'Blueberry' },
{ value: 'grapes', label: 'Grapes' },
{ value: 'pineapple', label: 'Pineapple' },
],
},
{
label: 'Vegetables',
items: [
{ value: 'aubergine', label: 'Aubergine' },
{ value: 'broccoli', label: 'Broccoli' },
{ value: 'carrot', label: 'Carrot', disabled: true },
{ value: 'courgette', label: 'Courgette' },
{ value: 'leek', label: 'Leek' },
],
},
]
const languages = [
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'rust', label: 'Rust' },
{ value: 'go', label: 'Go' },
{ value: 'swift', label: 'Swift' },
]
const timezones = [
{ value: 'utc', label: 'UTC' },
{ value: 'america/new_york', label: 'Eastern Time (ET)' },
{ value: 'america/chicago', label: 'Central Time (CT)' },
{ value: 'america/los_angeles', label: 'Pacific Time (PT)' },
{ value: 'europe/london', label: 'Greenwich Mean Time (GMT)' },
{ value: 'europe/berlin', label: 'Central European Time (CET)' },
{ value: 'asia/tokyo', label: 'Japan Standard Time (JST)' },
]
function languageSummary(value: string[]) {
if (value.length === 0) return 'Select languages'
let first = languages.find((language) => language.value === value[0])?.label ?? value[0]
return value.length > 1 ? `${first} (+${value.length - 1} more)` : first
}
export const SelectDemo = clientEntry(import.meta.url, function SelectDemo(handle: Handle) {
let submitted: Array<[string, string]> | null = null
return () => (
<div class="flex w-full max-w-sm flex-col gap-8">
<div class="flex flex-col gap-2">
<Label id="select-demo-produce-label">Produce</Label>
<Select items={produce}>
<SelectTrigger class="w-48" aria-labelledby="select-demo-produce-label">
<SelectValue placeholder="Select produce" />
</SelectTrigger>
<SelectContent>
{produce.map((group, index) => (
<>
{index > 0 ? <SelectSeparator /> : null}
<SelectGroup>
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((item) => (
<SelectItem value={item.value} disabled={item.disabled}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</>
))}
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-2">
<Label id="select-demo-languages-label">Languages</Label>
<Select multiple defaultValue={['javascript', 'typescript']} items={languages}>
<SelectTrigger class="w-56" aria-labelledby="select-demo-languages-label">
<SelectValue>{(value: string[]) => languageSummary(value)}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{languages.map((language) => (
<SelectItem value={language.value}>{language.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<form
class="flex flex-col gap-3"
mix={on<HTMLFormElement, 'submit'>('submit', (event) => {
event.preventDefault()
submitted = [...new FormData(event.currentTarget).entries()].map(([key, value]) => [
key,
String(value),
])
void handle.update()
})}
>
<div class="flex flex-col gap-2">
<Label id="select-demo-timezone-label">Timezone</Label>
<Select name="timezone" required items={timezones}>
<SelectTrigger class="w-64" aria-labelledby="select-demo-timezone-label">
<SelectValue placeholder="Select a timezone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{timezones.map((timezone) => (
<SelectItem value={timezone.value}>{timezone.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div>
<Button type="submit" size="sm">
Submit
</Button>
</div>
<pre class="rounded-md bg-muted p-3 text-xs" data-testid="select-demo-output">
{submitted
? submitted.map(([key, value]) => `${key}: ${value}`).join('\n') || '(empty)'
: 'Submit the form to see its FormData.'}
</pre>
</form>
</div>
)
})
Installation
npx shadcn add @stemsui/selectRequires the one-time setup. Behavior: stems-ui/select.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/select
A button that opens a list of options to pick one value, or several with multiple. Equivalent
of Base UI's Select. Built on lib/popup.ts (open
state, top-layer popup, dismissal, positioning) and lib/listbox.ts (value, options, highlight,
keyboard, typeahead).
Usage
import * as select from 'stems-ui/select'
let fruits = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
]
function FruitSelect() {
return () => (
<form>
<select.Context name="fruit" required items={fruits} defaultValue="banana">
<div mix={select.label()}>Fruit</div>
<button mix={select.trigger()}>
<span mix={select.value()}>
<select.ValueText placeholder="Pick a fruit" />
</span>
<span mix={select.icon()}>▾</span>
</button>
<div mix={select.popup()}>
<div mix={select.scrollUpArrow()}>▴</div>
<div mix={select.list()}>
<div mix={select.group()}>
<div mix={select.groupLabel()}>Fruits</div>
{fruits.map((fruit) => (
<select.ItemContext value={fruit.value}>
<div mix={select.item()}>
<span mix={select.itemText()}>{fruit.label}</span>
<span mix={select.itemIndicator()}>✓</span>
</div>
</select.ItemContext>
))}
</div>
</div>
<div mix={select.scrollDownArrow()}>▾</div>
</div>
</select.Context>
</form>
)
}
Multiple selection: the value is an array, the popup stays open while items are toggled, and
ValueText joins the labels with ", " (or pass a render function):
<select.Context multiple name="languages" defaultValue={['js', 'ts']}>
…
<span mix={select.value()}>
<select.ValueText>{(value: string[]) => `${value.length} selected`}</select.ValueText>
</span>
…<div mix={select.popup({ alignItemWithTrigger: false })}>…</div>
</select.Context>
Listen for changes with the callback or the bubbling event:
<select.Context onValueChange={(value, details) => console.log(value, details.reason)}>…
<div mix={select.onSelectChange((event) => console.log(event.value, event.reason))}>…</div>
API
Context (Base UI Select.Root)
Owns the state and renders the form input(s) after its children (Base UI renders its hidden input
in Root too).
| Prop | Default | Notes |
|---|---|---|
value / defaultValue | null | A value or null; an array with multiple. Values may be any type (compared with isItemEqualToValue). |
onValueChange(value, details) | details: reason ('item-press' | 'list-navigation' | 'none'), event, cancel(), isCanceled. | |
multiple | false | |
open / defaultOpen | false | |
onOpenChange(open, details) | Base UI reasons: trigger-press, outside-press, escape-key, item-press, focus-out, list-navigation, none. cancel(). | |
onOpenChangeComplete(open) | After the enter / exit animation. | |
modal | true | Locks scroll, makes outside content inert, traps focus. 'trap-focus' or false to relax. |
name, form, autoComplete | Form participation. | |
required | false | Native constraint validation; an invalid submit focuses the trigger. |
disabled, readOnly | false | readOnly keeps the popup closed and ignores typeahead. |
items | { value: label }, [{ value, label }], or groups [{ items: [...] }]. Used for the value label, including in server HTML. | |
itemToStringLabel / itemToStringValue | Label for display / string for the form. Objects with label / value keys work without them. | |
isItemEqualToValue | Object.is | |
highlightItemOnHover | true | |
loopFocus | false | Stems extra: wrap keyboard navigation. |
onItemHighlighted(value, details) | Stems extra. |
handle.context.get(select.Context) returns SelectContextValue: open, value,
selectedValues, hasValue, labelOf(value), stringValue(value), setValue(), setOpen(),
plus the underlying store (popup) and listbox.
Parts
| Part | Element | Options |
|---|---|---|
label() | any | Labels the trigger and list (aria-labelledby); a press focuses the trigger without opening. |
trigger(options?) | button | disabled. |
value() | span | Host for ValueText. |
<ValueText> | (text) | placeholder, children(value). Renders the label(s); no element of its own. |
icon() | any | |
popup(options?) | div | alignItemWithTrigger (true), side, align, sideOffset, alignOffset, collisionPadding, arrowPadding, anchor, initialFocus, finalFocus. |
list() | div | Without it, the popup becomes the listbox (after hydration). |
<ItemContext> | — | value, label, disabled. Required for itemIndicator(); item() inside it reads its options from it. |
item(options?) | div | value, label (typeahead / display text), disabled. |
itemText() | any | The item's text: used as its label instead of the full text content. |
itemIndicator(options?) | any | keepMounted. |
group() / groupLabel() | div | |
separator() | div | The generic stems-ui/separator mixin. |
scrollUpArrow(options?) / scrollDownArrow(options?) | div | keepMounted. Scroll the list while hovered (not on touch). |
arrow(), backdrop() | div |
Base UI's Portal and Positioner have no Stems equivalent: the popup is shown in the top layer
where it is rendered, and the positioner options go to popup().
Events
stems:select-change→SelectChangeEvent(value,reason), from the trigger. Listener:onSelectChange(handler).stems:select-open-change→SelectOpenChangeEvent(open,reason). Listener:onSelectOpenChange(handler).
Behavior
- Keyboard (trigger): Enter, Space, ArrowDown open (highlighting the selected item, else the first); ArrowUp opens on the selected item, else the last. Typing selects the next matching item without opening (single selection; Space continues a search in progress).
- Keyboard (list): ArrowDown / ArrowUp, Home / End (disabled items are skipped; no wrap
unless
loopFocus), Enter / Space select, typing highlights the next match (repeat a letter to cycle), Escape closes, Tab closes and moves to the next tabbable after the trigger. - Single: pressing an item selects it and closes the popup; focus returns to the trigger. Multiple: pressing toggles it and the popup stays open.
- Focus: items get real DOM focus (
tabIndex={-1});data-highlightedfollows focus and the pointer. Opening with the pointer and no value focuses the list. - Positioning: with
alignItemWithTrigger(default) the popup overlaps the trigger so the selected item (or the first item) lines up with the value text, anddata-sideis"none". It falls back to regular placement (side/align, default below) when opened by touch or when the trigger is within 20px of the top or bottom of the viewport. The mode is decided once per opening. - Forms: the provider renders a visually hidden
<input>carryingname, the value,required,disabled,form,autoComplete; withmultiple, one<input type="hidden">per value (and the unnamed validation input). Focusing the input (e.g. an invalid submit) focuses the trigger. - Labels:
ValueTextshows, in order:items,itemToStringLabel, the registered item'slabel/ item text, an object value'slabel, the string value. Items register on the client, so passitems(oritemToStringLabel) for a correct label in server-rendered HTML; without it the server renders the raw value and the label appears after hydration.
Styling hooks
| Part | Data attributes | CSS variables |
|---|---|---|
| trigger | data-popup-open, data-pressed, data-placeholder, data-disabled, data-readonly, data-required; aria-expanded | |
| value | data-placeholder | |
| icon | data-popup-open | |
| popup | data-open / data-closed, data-side (none when aligned), data-align, data-starting-style / data-ending-style | --anchor-width, --anchor-height, --available-width, --available-height, --transform-origin |
| item | data-selected, data-highlighted, data-disabled; aria-selected | |
| itemIndicator | data-selected, data-starting-style / data-ending-style | |
| scroll arrows | data-direction (up / down), data-visible, data-side | |
| arrow | data-open / data-closed, data-side, data-align, data-uncentered | |
| backdrop | data-open / data-closed, data-starting-style / data-ending-style | |
| separator | data-orientation |
Scroll arrows without their own position become position: sticky overlays (with a negative
margin, so showing them doesn't shift the items) inside the scrolling element, or
position: absolute when placed outside it. Style top: 0 / bottom: 0.
Inside a field, the trigger also gets data-valid, data-invalid, data-dirty, data-touched,
data-filled, data-focused, and aria-invalid="true" while invalid.
Field integration
Inside <field.Context> the select is the field's control:
- The trigger gets the field's control id (unless it has its own
id, which the field then follows), so<label mix={field.label()}>targets it; it isaria-labelledbytheselect.label()or else the field label, andaria-describedbythe field's descriptions and visible errors. A native<label>click also activates the trigger (opens the popup); use a non-<label>host (orselect.label()) to only focus it. - The field's
namewins for the hidden input(s).requiredand the field'svalidate(called with the value) run on the visually hidden input; the trigger is focused when the field blocks a form submission. Servererrorsshow until the value changes. - Focus moving between the trigger and the popup stays inside the field:
data-focusedstays anddata-touched(andvalidationMode="onBlur") waits until focus leaves both. - The field's (or an enclosing
fieldset.Context's)disableddisables the select.
Not implemented (yet)
- Press-drag-release selection (open on pointer down, select on pointer up),
actionsRef,inputRef, the trigger'sdata-popup-side,disableAnchorTracking,collisionAvoidance. - Item-aligned mode doesn't grow the popup while scrolling like Base UI; the list scrolls inside the aligned popup.
- Browser autofill writing into the hidden input isn't read back.