DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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/ui

Usage

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

PropTypeDefaultDescription
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 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
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 macOS-style overlay; 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 below md; 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)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-owns semantics Safari doesn't implement; an inline-popup mode is tracked in the backlog (the Fluent inlinePopup model).
  • 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
SegmentedControlShortcut

On this page

PlaygroundInstallationUsageWhen to use — and when not toExamplesDefaultSizesGhost variantIntentsValidationMultipleOn-colorLeading IconItem DescriptionsTrailing metaStatus dotsGrouped OptionsScrollableStatesCustom value renderingFooter actionAPI ReferenceSelectRoot propsSelectTrigger propsSelectItem propsSelectPopup propsSelectSkeleton propsAccessibilityKeyboardAT matrixKnown limitationsMigrating