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.
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 |
Variants, properties, states
Variants
Structurally different versions of the component.
defaultinline Properties
The same component, parameterised.
| Property | Type |
|---|---|
multi | boolean |
required | boolean |
size | sm | md | lg |
native | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | closedopeninvalid |
State transitions
| From | To | Trigger |
|---|---|---|
closed | open | User 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. |
open | closed | User 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. |
closed | invalid | `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"`. |
invalid | closed | User makes a selection (regardless of which option). `aria-invalid` is removed; the inline error is cleared. |
Cross-framework expression
| Framework | Structure mechanism | Variant 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 |
Events
selectionChangeopenChangeinvalidChange
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.
Performance thresholds
switchToComboboxoption-count≥25itemsAbove ~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-count≥200itemsFor 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.
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. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus 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
| Trigger | Expected |
|---|---|
| Trigger receives focus, listbox closed | SR 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 opens | SR 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 ArrowDown | SR 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 blur | Trigger gets `aria-invalid="true"`; the inline error referenced via `aria-describedby` is announced ("Please make a selection"). |
axe-core rules to assert
aria-required-attraria-valid-attr-valuearia-rolesaria-required-childrenaria-required-parentcolor-contrastselect-name
Common mistakes
#select-not-button-or-listbox
Select implemented as a styled `<div>` with click handlers
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).
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
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.
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`
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`.
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
`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.
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`.
#select-too-many-options-no-search
Select with hundreds of options and no search
The listbox renders 200+ options without filtering or typeahead-search. The user scrolls helplessly; SR users cycle through every option to find the one they want. The pattern collapses to "needs Combobox".
Above ~25 options, a Combobox (with text-input filter) is almost always the right pattern. Document the canonical threshold; use Select for short fixed lists where typeahead-by-letter is sufficient. Above the threshold, switch to Combobox.
Figma↔Code mismatches
- 01 Figma
A select drawn as a custom popup with the native chevron icon
CodeA custom listbox (portal-mounted div with role="listbox") with full APG keyboard contract
ConsequenceDesigners 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.
CorrectDocument `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.
- 02 Figma
Multi-select drawn with comma-separated value list growing in the trigger
CodeMulti-select with selected-count display ("3 of 5 selected") that does not reflow the trigger
ConsequenceDesigners 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.
CorrectMulti-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.
- 03 Figma
Select drawn at native `<select>` styling with no custom variant
CodeA custom listbox replicating native `<select>` to within a few pixels
ConsequenceDesigners 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.
CorrectDocument 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.
- 04 Figma
Disabled options drawn greyed-out with no `aria-disabled`
CodeDisabled options that are not focusable, not announceable, and silently un-clickable
ConsequenceDesigners 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).
CorrectDisabled 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).