Select
Field-shell dropdown for choosing one value — or several — from a fixed list: intents, ghost variant, on-color tone, grouped and rich options, full keyboard and screen-reader wiring.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
SelectRoot,
SelectTrigger,
SelectPopup,
SelectList,
SelectItem,
} from "@tessinaui/ui";<SelectRoot label="Fruit" value={value} onValueChange={setValue}>
<SelectTrigger placeholder="Select a fruit…" />
<SelectPopup>
<SelectList>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectList>
</SelectPopup>
</SelectRoot>When to use — and when not to
- Select — choose one value (or a few, with
multiple) from a known, fixed list. Typeahead is built in; there is no text filtering. - Past ~15 options, or any list a user would search rather than scan, reach for Combobox — it owns the filter input. Don't bolt a search field into a Select popup.
- Rows that act (delete, invite, navigate) belong in DropdownMenu — a listbox may only contain options; an action in an option list is a contract violation, and the dev build warns about focusables inside the list.
- A compact unit/currency selector attached to an input is FieldDropdown.
- A "guests / travellers" trigger that opens counter rows is a Popover + NumberField composition, not a Select.
- Time-period triggers that expand into calendars are DatePicker territory.
- Two or three visible choices → RadioGroup or SegmentedControl.
- A clearable single select is an explicit null item:
<SelectItem value={null}>None</SelectItem>.
Examples
Default
A labelled select with a placeholder and a default selected value. The visible
label is the accessible name (htmlFor association — clicking it focuses the
trigger), and supportingText is announced via aria-describedby.
Sizes
Five trigger heights and text scales — xs, sm, md, lg, and xl. The
shell text reads Field's own ladder, so a Select next to a Field always
matches, 16px phone floor included.
Ghost variant
variant="ghost" drops the well for toolbars, table footers and inline
config rows. A ghost trigger still needs a name — use visuallyHiddenLabel
when the label would be visual noise.
Intents
Border and focus-ring colours for none, error, warning, success, and
info. Warning helper text uses the tinted token — raw amber never renders as
text.
Validation
errorMessage REPLACES supportingText (no layout shift), implies
intent="error" unless a non-none intent is explicit, sets aria-invalid,
and the swap is announced politely.
Multiple
multiple keeps the popup open while toggling, renders checkbox rows, and
summarises the trigger: one selection shows its label (via the Root items
map), more show "N selected". renderValue on the trigger is the
formatting/i18n escape hatch.
On-color
On a coloured Surface the trigger adopts the shared well recipe — tone resolves from context, no prop needed. The popup stays a standard panel: a portal floats above the plate, it is not on it.
Leading Icon
Add an icon at the start of the trigger via the leadingIcon prop.
Item Descriptions
Two-line options via the description prop. The description stays out of
the option's accessible name (it's wired as the option's description), so
"Apple" announces as Apple, never "Apple Crisp and sweet".
Trailing meta
trailingText renders muted meta at the row's end — a phone code, a shortcut
hint, a balance — outside the accessible name.
Status dots
Colour-coded options compose today: any node in leadingIcon.
Grouped Options
Base UI group primitives: each group is named by its label for screen readers.
Scrollable
The List caps to the viewport (--available-height) and scrolls; hover the
arrows on desktop. On touch the arrows never render — native scrolling is the
path.
States
disabled, readOnly (focusable and announced, but not operable), and
disabled options.
Custom value rendering
renderValue plus the Root items map: echo the selected item's icon in the
trigger, format joined labels, localise the multi summary.
Footer action
A "Create…" row below the options. It's legal because the List carries
role="listbox" and the Popup demotes to role="presentation" — the button
sits outside the listbox. It is Tab-reachable, not part of the arrow-key
option navigation.
API Reference
SelectRoot props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Trigger height and text scale (32/36/40/48/56px; 44px floors on phones) |
variant | "outline" | "ghost" | "outline" | Bordered form well, or the borderless toolbar/inline trigger |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border, focus-ring and supporting-text colour |
tone | "default" | "on-color" | from Surface context | Adopts the shared on-color well recipe on coloured plates |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Trigger radius; popup and items step proportionally (panel caps at 24px) |
labelPlacement | "top" | "inside" | "start" | "top" | Logical label layout |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
label | string | — | Label text — the trigger's accessible name |
visuallyHiddenLabel | boolean | — | Keep the name for AT, hide the label row (toolbar/ghost usage) |
required | boolean | — | Star on the label + aria-required + Base UI form enforcement |
optional | boolean | — | Renders "(optional)" after the label (ignored when required) |
infoText | string | — | Inline hint beside the label, joined into aria-describedby |
supportingText | string | — | Helper row below the trigger (described-by, polite region) |
errorMessage | string | — | REPLACES supportingText; implies intent="error" unless a non-none intent is set; sets aria-invalid |
multiple | boolean | false | Array values, checkbox rows, stays open while toggling, "N selected" summary |
items | Record<string, ReactNode> | {label, value}[] | — | Lets SelectValue render labels instead of raw values (drives the single-selection summary in multiple) |
value / defaultValue | string | null — or string[] with multiple | — | Controlled / uncontrolled selection |
onValueChange | (value, eventDetails) => void | — | Selection change (array with multiple) |
disabled | boolean | — | Disables the whole select |
readOnly | boolean | — | Focusable and announced, popup won't open |
name | string | — | Native form field name (hidden input) |
dir | "ltr" | "rtl" | — | Wires Base UI's DirectionProvider + the wrapper dir |
className | string | — | Class for the outermost wrapper |
labelPosition | — | — | Deprecated → labelPlacement (outside-top→top, outside-left→start) |
wrapperClassName | string | — | Deprecated → className |
Reference-only passthrough (Base UI Root): open / defaultOpen /
onOpenChange, onOpenChangeComplete, modal, actionsRef,
itemToStringLabel / itemToStringValue / isItemEqualToValue,
autoComplete, inputRef, form.
SelectTrigger props
| Prop | Type | Description |
|---|---|---|
leadingIcon | ReactNode | Icon at the start of the trigger, before the value |
placeholder | string | Text shown when no value is selected — never a label substitute |
renderValue | (value) => ReactNode | Custom selected-value rendering (receives the array with multiple) |
className | string | Extra classes on the trigger button |
SelectItem props
| Prop | Type | Description |
|---|---|---|
value | string | null | The value this item represents (null makes a clear/none item) |
disabled | boolean | Prevents selection |
label | string | Typeahead + trigger text override for rich children |
leadingIcon / leadingAvatar | ReactNode | Leading visual |
description | string | Secondary line — out of the accessible name, in the description |
trailingText | ReactNode | Muted trailing meta, outside the accessible name |
SelectPopup props
| Prop | Type | Description |
|---|---|---|
positionerProps | object | Forwarded to the Positioner (side, sideOffset, align… and alignItemWithTrigger — pass true to restore Base UI's macOS-style overlay; the house default is the detached panel) |
className | string | Extra classes on the popup panel |
SelectSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
size / rounded | as Root | "md" / "full" | The frame reads the SAME maps as the real trigger (§2b) |
showLabel / labelChars | boolean / number | false / 8 | Label bar, char-sized |
valueChars | number | 10 | Value bar width in characters |
showSupportingText / supportingChars | boolean / number | false / 24 | Helper bar |
showLeadingIcon | boolean | false | Leading icon block |
labelPlacement | "top" | "inside" | "start" | "top" | Mirrors the real layout (labelPosition deprecated alias) |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
label | string | — | Opt-in announcement; silent by default |
All other sub-components (SelectGroup, SelectGroupLabel, SelectSeparator,
SelectScrollUpArrow, SelectScrollDownArrow, SelectSupportingText,
SelectList) forward their Base UI part's props plus className.
Accessibility
Implements the WAI-ARIA select-only combobox pattern via Base UI: the
trigger is role="combobox" with aria-controls pointing at the
role="listbox" List (aria-multiselectable with multiple).
- The visible label is the accessible name (
htmlFor— click focuses).visuallyHiddenLabelkeeps the name when the row is hidden. There is noaria-labelduplication. supportingText/errorMessage/infoTextare joined intoaria-describedby; the meta row isaria-live="polite", and the error replaces the helper so validation never shifts layout.- Options announce as their label alone —
descriptionis the option's accessible description,trailingTextstays out of both. - Groups are named by
SelectGroupLabel. - Touch targets: 44px trigger + item floors below
md; forced-colors maps highlight/selection toHighlight/HighlightText; popup motion and the chevron rotation respectprefers-reduced-motion.
Keyboard
| Key | Context | Action |
|---|---|---|
Space / Enter / ArrowDown / ArrowUp | trigger | Open the popup (arrows also move the highlight) |
| type characters | trigger or open list | Typeahead to a matching option (SelectItem label overrides the match text for rich children) |
ArrowDown / ArrowUp | open | Move the highlight |
Home / End | open | First / last option |
Enter / Space | open | Select the highlighted option (with multiple, toggles and stays open) |
Esc | open | Close and return focus to the trigger |
Tab | open | Leaves the control (reaches a footer action, when present) |
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Untested — owed before stable (§5.6) |
| NVDA + Chrome (Windows) | Untested — cannot run on this machine; rides the next release pass |
Known limitations
- iOS VoiceOver + Safari: the portalled listbox relies on
aria-ownssemantics Safari doesn't implement; an inline-popup mode is tracked in the backlog (the FluentinlinePopupmodel). - The scroll arrows are a desktop-hover affordance and never render on touch — native scrolling covers it; don't make them the only overflow cue.
Migrating
| Old | New |
|---|---|
labelPosition="outside-top" | labelPlacement="top" |
labelPosition="outside-left" | labelPlacement="start" |
wrapperClassName | className |
open styling via data-[open] | data-popup-open (the attribute Base UI actually emits) |
| overlay popup (covered the trigger) | detached panel; positionerProps={{ alignItemWithTrigger: true }} restores the overlay |
getByRole("option", { name: /description/ }) | descriptions left the accessible name — query by label, assert toHaveAccessibleDescription |