Dev view

Select

A control that lets the user choose one or more values from a fixed list. Distinct from Combobox by the absence of a text-input front (no typeahead filtering); the user clicks or keyboard- navigates to a value and commits. Distinct from Radio by presenting choices in a popup rather than as visible alternatives. Renders as native `<select>` by default; custom implementations follow the APG Listbox pattern with portal- mounted popup, `aria-activedescendant` highlighting, and typeahead by first-letter.

When to use

Use

For choosing one or more values from a fixed list of ~3–25 options where typeahead-by-first-letter is sufficient. Common cases: country picker (when the list is short or grouped), priority picker, status setter, single-step config dropdowns. Native `<select>` is the canonical fallback and the right choice for mobile-first or platform-styled forms.

Avoid

For long lists where filter-by-typing improves discovery — that is `Combobox`. For 2–3 mutually-exclusive choices where all options should be visible — that is `Radio`. For multi-select where selections appear inline as removable tokens — that is `TagInput`. For free-text input with autocomplete suggestions — that is `Combobox`.

Versus related

  • combobox

    `Combobox` adds a text-input front for typeahead filtering; `Select` requires the user to scan or first-letter-jump through the visible list. Migrate from Select to Combobox when the option count crosses the "scrollable popup is annoying" threshold (~25 items in practice) or when the user benefits from partial-match filtering.

  • tag-input

    `TagInput` accepts multi-select with selected values rendered inline as removable tokens; `Select[multi]` shows a count or list in the trigger. TagInput is preferred for visible-on-page selection state where the user benefits from seeing tokens; Select is preferred for compact triggers in dense forms.

Highlight
Fig 1.1 · Select · Dev view
Dev

Code anatomy

Slot Code slot Semantic
trigger trigger combobox-button
value-display value text
caret caret presentational
listbox listbox listbox
option option option
option-group group group
Both

Variants, properties, states

Variants

Structurally different versions of the component.

defaultinline

Properties

The same component, parameterised.

PropertyType
multiboolean
requiredboolean
sizesm | md | lg
nativeboolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
closedopeninvalid
Both

State transitions

FromToTrigger
closedopenUser clicks the trigger, presses Enter / Space / ArrowDown / ArrowUp on the focused trigger, or types a printable character (typeahead opens and selects first matching option). `aria-expanded` flips to true; listbox renders.
openclosedUser selects an option (single-select); presses Escape; clicks outside the listbox; or tabs focus past the trigger. `aria-expanded` flips to false; listbox is removed.
closedinvalid`required: true` and the user blurs the trigger without making a selection (or commits a form containing an unselected required select). The trigger surfaces an inline error and sets `aria-invalid="true"`.
invalidclosedUser makes a selection (regardless of which option). `aria-invalid` is removed; the inline error is cleared.
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-select>` host wrapping native `<select>` when the `native` flag is true, or composing trigger plus listbox for custom; named slots for option content attributes (`variant="default"`, `multi`, `required`, `size="md"`, `native`); `data-state="open|closed|invalid"` for CSS
React Radix Select (`Select.Root` / `Select.Trigger` / `Select.Value` / `Select.Portal` / `Select.Content` / `Select.Item`); React Aria `useSelect` plus `useListBox`; for native, just `<select>` with styled wrapper props with class-variance-authority for variant / size; `multiple` boolean for multi-select; `data-state` exposed
Angular (signals) Angular CDK Listbox (`cdk-listbox`) plus Overlay for positioning; signal-based selection; native fallback via `<select>` directive input<'default' | 'inline'>(); input<'sm' | 'md' | 'lg'>(); `[multi]`, `[required]`, `[native]` host bindings
Vue Headless UI `<Listbox>` / `<ListboxButton>` / `<ListboxOptions>` / `<ListboxOption>`; native fallback via `<select>` element defineProps with literal-union types; `:multiple` boolean; `:size` enum
Both

Events

  1. selectionChange
    Payload
    The selected value (single-select) or array of selected values (multi-select), or `null` / `[]` after clear. Always reflects the canonical value (id or token), not the option's display label.
    Web Components
    `change` CustomEvent on the host with `event.detail = { value }` (or `{ values }` for multi).
    React
    `onValueChange(value: string)` (Radix Select) or `onSelectionChange(key | Set<Key>)` (React Aria).
    Angular Signals
    `output<T | T[] | null>('selectionChange')`.
    Vue
    `@update:modelValue` for `v-model`.
  2. openChange
    Payload
    Boolean. `true` when the listbox opens, `false` when it closes. Mirrors `aria-expanded` on the trigger.
    Web Components
    `openChange` CustomEvent with `event.detail = { open }`.
    React
    `onOpenChange(open: boolean)`.
    Angular Signals
    `output<boolean>('openChange')`.
    Vue
    `@update:open` for `v-model:open`.
  3. invalidChange
    Payload
    Boolean. Fires when the validity state changes — typically on blur with `required: true` and no selection (true), or on selection that resolves the invalid state (false).
    Web Components
    `invalidChange` CustomEvent. Native `<select>` fires `invalid` event; the host re-emits to unify with custom.
    React
    `onInvalidChange(invalid: boolean)` callback. React Aria and Radix surface validity through their form-state APIs.
    Angular Signals
    `output<boolean>('invalidChange')`; reactive forms integration provides this through the FormControl status.
    Vue
    `@invalid` event with payload `{ invalid }`.
Dev

Form integration

name attribute
Native `<select>` carries the `name` attribute directly; custom selects either render an internal hidden `<select>` or `<input type="hidden">` to participate in form submission, or wire form-state via the consumer (React Hook Form, Vue's v-model). The canonical reference treats `name` as a passthrough to the underlying form-participating element.
FormData serialization
Single-select submits the selected option's value (id / token), not the display label, under the configured `name`. Multi-select submits one entry per selected value (multiple FormData entries with the same `name`) to match `<select multiple>` native behaviour. Required-and- unselected results in no entry (plus validation failure on submit).
form.reset()
`form.reset()` restores the select's selected value to its `defaultValue`. For native `<select>` this is the option with the `selected` attribute (or first option when none is marked). For custom selects, the consumer's form-state library handles reset via its own contract; `onReset` listener on the form syncs.
HTML5 validation
`required` triggers HTML5 validation on submit; native `<select>` shows the platform's invalid bubble; custom selects use `setCustomValidity()` plus `aria-invalid`. The canonical inline-error pattern surfaces a user-facing message via `aria-describedby` ("Please make a selection"). The select's `:invalid` pseudo-class state propagates to `:invalid` on the parent form.
Dev

Performance thresholds

  • switchToComboboxoption-count25items

    Above ~25 options, the typeahead-by-first-letter pattern starts to fail (multiple options share initial letters, cycling becomes tedious) and Combobox (with text-input filter) is almost always the right pattern. Below 25, Select with typeahead is sufficient. Document the threshold; switching is a redesign signal, not a progressive enhancement.

  • virtualisedListboxoption-count200items

    For Select implementations that absolutely must handle large lists (rare canonical case — usually a Combobox is preferred), virtualisation kicks in above ~200 options. Mirrors the Combobox threshold for consistency in the ecosystem.

Both

Accessibility

Slot Accessibility hint
trigger Apply `role="combobox"` (the APG Combobox pattern covers select-without-textbox via `aria-haspopup="listbox"` plus `aria-activedescendant`), `aria-expanded` reflecting open state, `aria-controls` referencing the listbox id. Native `<select>` is the canonical fallback — has all these contracts implicitly via the platform.
value-display Plain text. The trigger's accessible name is the value display when the select has no separate label; pair the trigger with a visible `<label>` for complete a11y. Multi-select counts ("3 selected") work canonically; comma-separated lists may overwhelm SR users on long selections.
caret Decorative — `aria-hidden="true"`. Open state is communicated by `aria-expanded`; the caret visualises it.
listbox Apply `role="listbox"` and an `id` referenced by the trigger's `aria-controls`. Listbox is rendered via portal but DOM focus stays on the trigger; do not move focus into the listbox. `aria-multiselectable="true"` for multi-select variants.
option Apply `role="option"` plus a unique `id` per option. The currently-highlighted option is referenced by the trigger's `aria-activedescendant` (NOT focused — DOM focus stays on the trigger). Selected options have `aria-selected="true"`; multi-select shows a visual check (typically a checkmark).
option-group `role="group"` with `aria-labelledby` referencing the group label. Group label is non-selectable (`role="presentation"` or styled `<span>`). SR announces the group label before announcing options inside.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus enters the trigger. Native `<select>` allows ArrowDown / ArrowUp to change selection without opening (browser-specific); custom implementations always open on ArrowDown / ArrowUp / Enter / Space.
Enter / Space / ArrowDown / ArrowUp (focus on trigger, listbox closed)Opens the listbox and highlights the currently-selected option (or the first option if none selected). DOM focus stays on the trigger; the highlighted option is referenced by `aria-activedescendant`.
ArrowDown / ArrowUp (listbox open)Moves the highlight to the next / previous option. `aria-activedescendant` updates. Skips disabled options. Wraps from last to first and vice versa.
Home / End (listbox open)Highlights first / last option respectively.
Enter (listbox open)Commits the highlighted option (single-select); listbox closes. For multi-select, toggles the highlighted option's selected state without closing.
Escape (listbox open)Closes the listbox without committing. Trigger retains focus and the previously-selected value.
typeahead character keys (focus on trigger or listbox open)Highlights the next option whose label starts with the typed character. Sequential same-character presses cycle through matches; multi-character within ~500ms accumulate (e.g. "fer" jumps to "Ferrari" before "Fiat").

Screen-reader announcements

TriggerExpected
Trigger receives focus, listbox closedSR announces "<accessible name>, combobox, collapsed, <selected value or 'No selection'>". The combobox role and expanded state come from APG; the value comes from the trigger's accessible name composition.
Listbox opensSR announces "expanded" plus the highlighted option ("<option label>, <position> of <total>, selected" if already selected). The user can hear the option count and current position.
Option highlighted via ArrowDownSR announces "<option label>, <position> of <total>" (with "selected" suffix if applicable). Driven by `aria-activedescendant` updating to the highlighted option's id.
Option committed (Enter)SR announces the new selection. Listbox closes; focus stays on trigger; trigger's value-display updates.
Required validation fails on blurTrigger gets `aria-invalid="true"`; the inline error referenced via `aria-describedby` is announced ("Please make a selection").

axe-core rules to assert

  • aria-required-attr
  • aria-valid-attr-value
  • aria-roles
  • aria-required-children
  • aria-required-parent
  • color-contrast
  • select-name
Dev

Common mistakes

#select-not-button-or-listbox

Select implemented as a styled `<div>` with click handlers

Problem

The trigger is a `<div>` with `onclick` opening a popup that has no `role="listbox"`. SR users hear nothing about a select — no role, no expanded state, no controls reference. Keyboard users get whatever the consumer wired manually (often nothing).

Fix

Trigger is a `<button>` (or native `<select>`); listbox has `role="listbox"`; options have `role="option"`. Mature primitives (Radix Select, React Aria `useListBox`+`useSelect`) provide the entire contract.

#select-no-typeahead

Typeahead by first-letter not implemented

Problem

User types a letter expecting to jump to the next option starting with that letter (a canonical APG behaviour since OS-level select pickers); nothing happens or the letter is captured by an unrelated handler. Users with long option lists must arrow-key through every entry.

Fix

Implement first-letter typeahead per APG: typing matches the first option whose label starts with that letter, moving `aria-activedescendant`. Sequential same-letter presses cycle through matches. Multi-character timeout (~500ms) accumulates the buffer to allow "fer" jumping to "Ferrari" before "Fiat".

#select-listbox-clipped

Listbox clipped by parent `overflow: hidden`

Problem

The listbox is rendered as a child of the trigger's DOM ancestor. A scrollable parent clips the listbox when it extends beyond. Z-index does not help because the clipping is at paint time. Same failure mode as `popover-z-index-clipping`.

Fix

Render the listbox via a portal at the document root. Native `<select>` portal-mounts automatically. Custom implementations use a Portal / Teleport primitive.

#select-required-silent-failure

Required select submits without selection silently

Problem

`required: true` on a select; user submits the form without selecting; submission proceeds (custom form handler) without error indication, or the form rejects with a generic "fill all fields" message that does not identify the specific select.

Fix

Trigger HTML5 validation on submit (native `<select>` does this automatically; custom uses `setCustomValidity()` plus `aria-invalid`). On validation failure, focus the unselected select and surface an inline error referenced via `aria-describedby`.

Figma↔Code mismatches
  1. 01
    Figma

    A select drawn as a custom popup with the native chevron icon

    Code

    A custom listbox (portal-mounted div with role="listbox") with full APG keyboard contract

    Consequence

    Designers may draw a custom select with the native browser chevron; developers face a choice between native `<select>` (loses visual control over options, follows OS picker) and a full custom listbox (gains visual control, requires implementing the entire APG keyboard contract: typeahead, ArrowKeys, Home/End, PageUp/PageDown, `aria-activedescendant`, multi-select chord-keys). The Figma file frequently does not encode which path was chosen.

    Correct

    Document `native: boolean` as a first-class property. The canonical default is custom (matches design-system visuals); native is an opt-in for situations where the platform picker is preferred (mobile, system-styled forms). The Figma file carries `native` as a Boolean.

  2. 02
    Figma

    Multi-select drawn with comma-separated value list growing in the trigger

    Code

    Multi-select with selected-count display ("3 of 5 selected") that does not reflow the trigger

    Consequence

    Designers compose multi-select mocks with selected values listed inline in the trigger ("Apple, Banana, Cherry, Durian, Eggplant"); the trigger grows unboundedly and truncates at the inline-end. Developers ship the mock faithfully and selecting many items breaks the form layout.

    Correct

    Multi-select trigger displays a count ("3 selected" or "3 of 5") rather than a comma-separated list. The visible value is intrinsically bounded; long selections do not break layout. Document the count-display pattern and reserve comma-separated for very-short maximum selections.

  3. 03
    Figma

    Select drawn at native `<select>` styling with no custom variant

    Code

    A custom listbox replicating native `<select>` to within a few pixels

    Consequence

    Designers may approve a "use native" path then designers rebuild the option list visually because the native picker ignores most CSS. Developers shipping native lose visual parity with the design; shipping custom required reimplementing the platform keyboard contract.

    Correct

    Document the trade-off canonically. Native is invisible to CSS for the popup itself (only the trigger is style-able); custom requires implementing the entire APG contract. Choose per use case; document via `native` property; ship one or the other consistently per design system.

  4. 04
    Figma

    Disabled options drawn greyed-out with no `aria-disabled`

    Code

    Disabled options that are not focusable, not announceable, and silently un-clickable

    Consequence

    Designers grey out unavailable options visually; developers either skip them in the listbox (SR users do not know they exist) or include them with no `aria-disabled` (SR announces them as available, click is a silent no-op).

    Correct

    Disabled options are present in the listbox with `aria-disabled="true"`. SR users hear "<label>, dimmed, option" — they know the option exists and is unavailable. Selection skips disabled options on arrow-key navigation; direct click is a no-op (no error toast, just silent skip).