DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. Select

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.

$ pnpm add @tessinaui/ui

When to use

  • One value from a fixed list that is too long for radios: a country, a time zone, a sort order, a role.
  • Options the user already recognises. Picking beats typing when the choices are known and short enough to scan.
  • Limited space. The trigger shows only the current value, so the list costs no room until it is opened. Use variant="ghost" for a quiet toolbar or inline trigger.
  • Several values from the same list with multiple: the popup stays open while the user toggles rows and the trigger summarises the count.
  • A label that stays visible. Set label (or visuallyHiddenLabel where the row is hidden): the placeholder is a hint, never the name of the field.

When not to use

  • For two to four choices that should stay visible. Use SegmentedControl or Radio; hiding a short list behind a click costs a step for nothing.
  • For long or searchable lists. Typeahead only jumps to a match. When people need to filter, or type a value that is not listed, use Combobox.
  • For actions. A select records a value, it does not do something. Use DropdownMenu for commands.
  • For several independent on/off choices. Use Checkbox rather than multiple, so every option stays on screen.
  • For options that need long explanations. Keep each option to one line; description is a second line, not a paragraph. Put longer guidance in supportingText or the page.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  SelectRoot,
  SelectTrigger,
  SelectPopup,
  SelectList,
  SelectItem,
} from "@tessinaui/ui";
example.tsx
<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. One action under the list goes in a footer (see Footer action).
  • 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.

Underline variant

variant="underline" draws the Field family's bottom-rule shell for dense forms: square whatever rounded says, no fill in any state, and focus — or an open popup — shown as an inset 2px bottom band in the intent colour instead of the ring.

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.

RTL

dir="rtl" mirrors the trigger (value on the right, chevron on the left) and is restated on the portalled positioner, so the popup lines up with the trigger's right edge. Up and down arrow keys work the same. Leave dir unset and the select inherits the page's direction.

API Reference

SelectRoot props

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"Trigger height and text scale (32/36/40/48/56px; 44px floors on touch)
variant"outline" | "filled" | "ghost" | "underline""outline"Bordered form well, a tinted well with no outline until focus or an intent, the borderless toolbar/inline trigger, or the square bottom rule that focuses and opens with an inset band (ignores rounded)
intent"none" | "error" | "warning" | "success" | "info""none"Border, focus-ring and supporting-text colour
tone"default" | "on-color"from Surface contextAdopts 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
labelstring—Label text — the trigger's accessible name
visuallyHiddenLabelboolean—Keep the name for AT, hide the label row (toolbar/ghost usage)
requiredboolean—Star on the label + aria-required + Base UI form enforcement
optionalboolean—Renders "(optional)" after the label (ignored when required)
infoTextstring—Inline hint beside the label, joined into aria-describedby
supportingTextstring—Helper row below the trigger (described-by, polite region)
errorMessagestring—REPLACES supportingText; implies intent="error" unless a non-none intent is set; sets aria-invalid
multiplebooleanfalseArray values, checkbox rows, stays open while toggling, "N selected" summary
itemsRecord<string, ReactNode> | {label, value}[]—Lets SelectValue render labels instead of raw values (drives the single-selection summary in multiple)
value / defaultValuestring | null — or string[] with multiple—Controlled / uncontrolled selection
onValueChange(value, eventDetails) => void—Selection change (array with multiple)
disabledboolean—Disables the whole select
readOnlyboolean—Focusable and announced, popup won't open
namestring—Native form field name (hidden input)
dir"ltr" | "rtl"—Wires Base UI's DirectionProvider + the wrapper dir
stringsPartial<SelectStrings>English, Arabic under dir="rtl"Override the optional marker, the trigger's placeholder ("Select…") and a multiple select's summary, selected ("{count} selected"), which takes plural variants, selected_<category> (selected_few), picked by Intl.PluralRules; overriding selected drops the Arabic table's variants. SelectTrigger's placeholder wins
classNamestring—Class for the outermost wrapper
labelPosition——Deprecated → labelPlacement (outside-top→top, outside-left→start)
wrapperClassNamestring—Deprecated → className

Reference-only passthrough (Base UI Root): open / defaultOpen / onOpenChange, onOpenChangeComplete, modal, actionsRef, itemToStringLabel / itemToStringValue / isItemEqualToValue, autoComplete, inputRef, form.

SelectTrigger props

PropTypeDescription
leadingIconReactNodeIcon at the start of the trigger, before the value
placeholderstringText shown when no value is selected — never a label substitute
renderValue(value) => ReactNodeCustom selected-value rendering (receives the array with multiple)
classNamestringExtra classes on the trigger button

SelectItem props

PropTypeDescription
valuestring | nullThe value this item represents (null makes a clear/none item)
disabledbooleanPrevents selection
labelstringTypeahead + trigger text override for rich children
leadingIcon / leadingAvatarReactNodeLeading visual
descriptionstringSecondary line — out of the accessible name, in the description
trailingTextReactNodeMuted trailing meta, outside the accessible name

SelectPopup props

PropTypeDescription
positionerPropsobjectForwarded to the Positioner (side, sideOffset, align… and alignItemWithTrigger — pass true to restore Base UI's overlay that covers the trigger; the house default is the detached panel)
classNamestringExtra classes on the popup panel

SelectSkeleton props

PropTypeDefaultDescription
size / roundedas Root"md" / "full"The frame reads the SAME maps as the real trigger (§2b)
showLabel / labelCharsboolean / numberfalse / 8Label bar, char-sized
valueCharsnumber10Value bar width in characters
showSupportingText / supportingCharsboolean / numberfalse / 24Helper bar
showLeadingIconbooleanfalseLeading 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
labelstring—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). visuallyHiddenLabel keeps the name when the row is hidden. There is no aria-label duplication.
  • supportingText / errorMessage / infoText are joined into aria-describedby; the meta row is aria-live="polite", and the error replaces the helper so validation never shifts layout.
  • Options announce as their label alone — description is the option's accessible description, trailingText stays out of both.
  • Groups are named by SelectGroupLabel.
  • Touch targets: 44px trigger + item floors on touch — a coarse pointer at any width, or a viewport under 768px; forced-colors maps highlight/selection to Highlight/HighlightText; popup motion and the chevron rotation respect prefers-reduced-motion.

Keyboard

KeyContextAction
Space / Enter / ArrowDown / ArrowUptriggerOpen the popup (arrows also move the highlight)
type characterstrigger or open listTypeahead to a matching option (SelectItem label overrides the match text for rich children)
ArrowDown / ArrowUpopenMove the highlight
Home / EndopenFirst / last option
Enter / SpaceopenSelect the highlighted option (with multiple, toggles and stays open)
EscopenClose and return focus to the trigger
TabopenLeaves the control (reaches a footer action, when present)

AT matrix

ATStatus
VoiceOver + Safari (macOS)Passed 2026-09-14. Arriving reads "Favourite fruit", "Apple". Space opens the list on the selected option, "Apple selected (1 of 6)"; Down reads "Banana (2 of 6)"; Enter commits and reads back "Banana, Favourite fruit, list box pop up, collapsed, combo box". Transcript: tests/qa/vo-transcripts/select.md
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-owns semantics Safari doesn't implement; an inline-popup mode — the list rendered in place rather than portalled — is tracked in the backlog.
  • 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

OldNew
labelPosition="outside-top"labelPlacement="top"
labelPosition="outside-left"labelPlacement="start"
wrapperClassNameclassName
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
RatingPicker

On this page

When to useWhen not to useInstallationUsageWhen to use — and when not toExamplesDefaultSizesGhost variantUnderline variantIntentsValidationMultipleOn-colorLeading IconItem DescriptionsTrailing metaStatus dotsGrouped OptionsScrollableStatesCustom value renderingFooter actionRTLAPI ReferenceSelectRoot propsSelectTrigger propsSelectItem propsSelectPopup propsSelectSkeleton propsAccessibilityKeyboardAT matrixKnown limitationsMigrating