Stems UI
Menu

Select

Choose one or more values from a dropdown list.

Submit the form to see its FormData.
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/select

Requires 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).

PropDefaultNotes
value / defaultValuenullA 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.
multiplefalse
open / defaultOpenfalse
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.
modaltrueLocks scroll, makes outside content inert, traps focus. 'trap-focus' or false to relax.
name, form, autoCompleteForm participation.
requiredfalseNative constraint validation; an invalid submit focuses the trigger.
disabled, readOnlyfalsereadOnly 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 / itemToStringValueLabel for display / string for the form. Objects with label / value keys work without them.
isItemEqualToValueObject.is
highlightItemOnHovertrue
loopFocusfalseStems 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
PartElementOptions
label()anyLabels the trigger and list (aria-labelledby); a press focuses the trigger without opening.
trigger(options?)buttondisabled.
value()spanHost for ValueText.
<ValueText>(text)placeholder, children(value). Renders the label(s); no element of its own.
icon()any
popup(options?)divalignItemWithTrigger (true), side, align, sideOffset, alignOffset, collisionPadding, arrowPadding, anchor, initialFocus, finalFocus.
list()divWithout 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?)divvalue, label (typeahead / display text), disabled.
itemText()anyThe item's text: used as its label instead of the full text content.
itemIndicator(options?)anykeepMounted.
group() / groupLabel()div
separator()divThe generic stems-ui/separator mixin.
scrollUpArrow(options?) / scrollDownArrow(options?)divkeepMounted. 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-highlighted follows 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, and data-side is "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> carrying name, the value, required, disabled, form, autoComplete; with multiple, one <input type="hidden"> per value (and the unnamed validation input). Focusing the input (e.g. an invalid submit) focuses the trigger.
  • Labels: ValueText shows, in order: items, itemToStringLabel, the registered item's label / item text, an object value's label, the string value. Items register on the client, so pass items (or itemToStringLabel) for a correct label in server-rendered HTML; without it the server renders the raw value and the label appears after hydration.

Styling hooks

PartData attributesCSS variables
triggerdata-popup-open, data-pressed, data-placeholder, data-disabled, data-readonly, data-required; aria-expanded
valuedata-placeholder
icondata-popup-open
popupdata-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
itemdata-selected, data-highlighted, data-disabled; aria-selected
itemIndicatordata-selected, data-starting-style / data-ending-style
scroll arrowsdata-direction (up / down), data-visible, data-side
arrowdata-open / data-closed, data-side, data-align, data-uncentered
backdropdata-open / data-closed, data-starting-style / data-ending-style
separatordata-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 is aria-labelledby the select.label() or else the field label, and aria-describedby the field's descriptions and visible errors. A native <label> click also activates the trigger (opens the popup); use a non-<label> host (or select.label()) to only focus it.
  • The field's name wins for the hidden input(s). required and the field's validate (called with the value) run on the visually hidden input; the trigger is focused when the field blocks a form submission. Server errors show until the value changes.
  • Focus moving between the trigger and the popup stays inside the field: data-focused stays and data-touched (and validationMode="onBlur") waits until focus leaves both.
  • The field's (or an enclosing fieldset.Context's) disabled disables the select.

Not implemented (yet)

  • Press-drag-release selection (open on pointer down, select on pointer up), actionsRef, inputRef, the trigger's data-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.