Designer 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.
- radio
`Radio` shows all options visible side-by-side or stacked, mutually-exclusive; `Select` hides options until the trigger is activated. Use Radio for 2–4 options where the choice itself communicates the decision (gender, plan tier, payment method). Use Select for 5+ options or when screen real estate is constrained.
- 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.
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
trigger | instance | Button-like surface with value display and caret; min-width drives field size |
value-display | text | Inline text style; truncates with ellipsis when overflowing |
caret | instance | Icon component instance; rotation bound to expanded state |
listbox | frame | Floating frame; width matches trigger by default |
option | instance | Option component instance with selected and highlighted variants |
option-group | frame | Group label plus indented options; visibility bound to "has groups" property |
Token usage per slot
trigger- spacing
- padding
spacing.compact - gap
spacing.compact
- padding
- radius
- corner
radius.md
- corner
- color
- background
color.surface.bg - foreground
color.text.primary - border
color.border.strong - ring
color.border.focus
- background
- typography
- size
text.md
- size
value-display- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md - lineHeight
leading.snug
- size
caret- color
- foreground
color.text.muted
- foreground
listbox- spacing
- padding
spacing.tight
- padding
- radius
- corner
radius.md
- corner
- color
- background
color.surface.raised - border
color.border.subtle
- background
- elevation
- shadow
elevation.lg
- shadow
option- spacing
- padding
spacing.compact
- padding
- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md
- size
Figma ↔ Code property map
| Figma | Type | Code | Notes |
|---|---|---|---|
Variant | Variant | variant | Maps default / inline. |
Size | Variant | size | sm / md / lg. |
Multi | Boolean | multi | Toggles single-vs-multi-select. Multi-select trigger displays a count by canon; comma-separated list is anti-pattern. |
Required | Boolean | required | Drives HTML5 validation on submit; canonical inline-error pattern via `aria-invalid` plus `aria-describedby`. |
Native | Boolean | native | Toggles native `<select>` rendering vs custom listbox. Canonical default is custom; native is an opt-in for mobile-first or platform-styled forms. At and below `breakpoint.sm` native becomes the canonical default regardless of authored value. |
Has Groups | Boolean | groups | Toggles the option-group slot. Use for long lists categorised by domain (countries by continent, files by folder). |
Disabled | Boolean | disabled | Disables the entire select. Disabled options are a separate concern (per-option Boolean). |
Placeholder | Text | placeholder | Shown in value-display when nothing is selected. SR users hear it as the trigger's accessible name fallback. |
Caret | Instance Swap | caret | Swap the chevron glyph; defaults vary per design system (down-arrow, triangle, double-arrow). |
Motion
| Transition | Duration token |
|---|---|
open | motion.duration.fast |
close | motion.duration.instant |
Responsive behaviour
| Breakpoint | Change |
|---|---|
breakpoint.sm | At and below, the canonical default switches to `native: true` — the platform picker provides better UX on touch (full-screen native picker, scroll-wheel on iOS, native search on Android). Visual variants (size, density) still apply to the trigger; the popup rendering follows the platform. |
breakpoint.md | Above this width, custom listbox renders as authored. Visual fidelity matches the design system; keyboard contract follows APG. |
Internationalisation
RTL · mirroring
Caret moves from inline-end of the trigger (visual right in LTR) to inline-end (visual left in RTL) via logical positioning. Listbox alignment follows the trigger's inline-start edge in both directions. Caret rotation is direction-neutral (down/up arrows are symmetric). Option content inherits document direction; mixed-direction labels (Hebrew options in an English-default form) honour each option's own `dir` attribute.
Text expansion
Trigger value display truncates with ellipsis when option labels exceed the trigger's inline-size; SR users hear the full label via `aria-label` on the trigger or `aria-activedescendant` on the option. Listbox width matches trigger width by default; long option labels may need a max-inline-size override. Long-text languages (German, Russian) frequently exceed common trigger widths — design size sm with care, or default to size md for international form designs.
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. |
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).
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.
Accessibility hints
| 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. |