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.

Also called Dropdown

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.

  • menu-button

    `Select` commits a value; `MenuButton` invokes an action. `aria-haspopup` differs (`listbox` vs `menu`) and the keyboard contracts diverge — Select keeps DOM focus on the trigger and uses `aria-activedescendant` for option highlight; MenuButton moves focus into the menu. A common confusion is rendering an "actions" menu as a Select-shaped trigger with action items inside; reach for MenuButton there.

  • radio-group

    `Select` houses options behind a popup trigger, suiting long bounded sets and dense forms; `RadioGroup` surfaces all options inline, suiting short sets where seeing every option aids decision. Both produce a single-value form submission. Migrate from RadioGroup to Select when the option count crosses ~7 and inline density becomes noisy; migrate from Select to RadioGroup when the set is short and the choice deserves visible weight.

Select is the form-bound dropdown for choosing a single value from a fixed list. It composes a button trigger with a popup listbox; the trigger displays the selected option, the listbox enumerates the choices, and the value participates in form submission via the underlying HTML select element. The reference covers the open-on-Space-or-Enter contract, the typeahead jump-to-option keyboard pattern, the divergence from Combobox (which accepts free-text input) and Menu Button (which fires actions rather than commits a value), and the platform-picker substitution that mobile user-agents apply.

Highlight
Fig 1.1 · Select · Designer view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

cdk MatSelect / MatOption / MatOptgroup / MatSelectTrigger (from @angular/material/select)
app.component.ts
import { Component, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatSelectModule, MatSelectChange } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@Component({
selector: 'app-demo',
standalone: true,
imports: [ReactiveFormsModule, MatSelectModule, MatFormFieldModule, MatInputModule],
template: `
<mat-form-field appearance="outline">
<mat-label>Priority</mat-label>
<mat-select
[formControl]="priorityCtrl"
[multiple]="false"
[hideSingleSelectionIndicator]="false"
[disableOptionCentering]="true"
panelClass="demo-select-panel"
(selectionChange)="onSelectionChange($event)"
(openedChange)="onOpenedChange($event)"
>
<mat-select-trigger>
{{ priorityCtrl.value?.label ?? 'Choose…' }}
</mat-select-trigger>
<mat-optgroup label="Active">
<mat-option [value]="{ id: 'high', label: 'High' }">High</mat-option>
<mat-option [value]="{ id: 'med', label: 'Medium' }">Medium</mat-option>
</mat-optgroup>
<mat-optgroup label="Archived" [disabled]="true">
<mat-option [value]="{ id: 'low', label: 'Low' }">Low</mat-option>
</mat-optgroup>
</mat-select>
<mat-error>Priority is required.</mat-error>
</mat-form-field>
`,
})
export class AppDemoComponent {
priorityCtrl = new FormControl<{ id: string; label: string } | null>(
null,
Validators.required,
);
onSelectionChange(event: MatSelectChange): void {
// event.value is the raw [value] binding; event.source is the MatSelect instance
}
onOpenedChange(isOpen: boolean): void {
// true = panel opened, false = panel closed
}
}

Divergence

From Type → To Rationale
anatomy[trigger] reshaped The trigger surface is a `<div role="combobox">` rendered internally by MatSelect — not a `<button>`. MatSelect sets `aria-haspopup="listbox"`, `aria-expanded`, `aria-controls` (referencing the panel id), and `aria-activedescendant` (referencing the highlighted option id) directly on this host element. `tabindex` is applied so the div is keyboard-reachable. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04). Canonical trigger is described as a "button-like surface" and assigns `role="combobox"` to it. MatSelect uses a `<div role="combobox">` rather than a native `<button>` because the select trigger must be an inline-flex container hosting both the value display and the arrow icon without the default button UA styling. The ARIA contract (role, aria-expanded, aria-controls, aria-activedescendant) is fully present; the host element type alone differs.
anatomy[value-display] extended + `<mat-select-trigger>` directive (selector: `[matSelectTrigger]`) — when projected inside `<mat-select>`, completely replaces the default value display with arbitrary consumer-authored content. Without this directive, MatSelect renders the selected option's `viewValue` as the trigger text and, in multiple mode, a comma-separated list of `viewValue`s. With `<mat-select-trigger>`, the consumer controls the full trigger inner content including icons, badges, or truncation logic. Source: github.com/angular/components blob src/material/select/select.ts and select.html (fetched 2026-05-04). Canonical `value-display` is a text slot showing the current selection; the canonical `caret` property mentions an icon swap but no full-content override. MatSelectTrigger goes further — it is a full content-projection escape hatch for consumers who need rich trigger content (e.g. colour swatches, user avatars, multi-chip display). This is a genuine extension beyond the canonical anatomy.
anatomy[caret] reshaped The arrow indicator is rendered as an internal SVG `<mat-select-arrow>` element inside the trigger host. It is not a composable consumer slot; there is no `[caretIcon]` input or ng-content projection point for it. The arrow rotates 180° via CSS class when the panel is open. The element is `aria-hidden="true"`. Source: github.com/angular/components blob src/material/select/select.html (fetched 2026-05-04). Canonical `caret` is described as a swappable icon slot (`code.slot: caret`, `semantic: presentational`). MatSelect bakes the arrow as an internal implementation detail with no consumer override point. Consumers who need a custom indicator must use `<mat-select-trigger>` and reproduce the arrow themselves, surrendering the built-in value-display text in exchange for full content control.
anatomy[listbox] reshaped The popup panel is not a separately authored element. MatSelect creates it internally via a CDK Overlay (`OverlayRef`) and a template reference; the panel element carries `role="listbox"` and is portalled into `.cdk-overlay-container` at the document root. Consumers do not author the listbox element; they project `<mat-option>` and `<mat-optgroup>` children into `<mat-select>` and MatSelect routes them into the panel. `panelClass: string | string[]` input applies additional CSS classes to the overlay panel for consumer styling. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04). Canonical `listbox` is described as a composable structural slot that the consumer authors (analogous to Headless UI `<ListboxOptions>` or Radix `<Select.Content>`). MatSelect instead auto-creates the panel, keeping the consumer surface minimal. The trade-off is that consumers cannot customise the panel's DOM structure beyond CSS classes and projected option content — but they gain automatic overlay positioning, scroll management, and keyboard focus without any consumer wiring.
anatomy[option] extended + `<mat-option>` renders a `MatPseudoCheckbox` inside each option when the parent `<mat-select>` has `[multiple]="true"`. The pseudo-checkbox provides visual multi-select feedback (checked / unchecked state) without a native `<input type="checkbox">` — it is an `aria-hidden` decorative element; the selection state is communicated via `aria-selected` on the `role="option"` host. `MatOption` is a shared class reused across MatSelect, MatAutocomplete, and MatChipListbox. It also exposes `hideSingleSelectionIndicator` (propagated from MatSelect) to suppress the selection checkmark in single-select mode. Source: github.com/angular/components blob src/material/core/option/option.ts (fetched 2026-05-04). Canonical `option` anatomy describes the common case without a library-specific checkmark decoration. MatOption extends this with first-class multi-select visual feedback (MatPseudoCheckbox) and a single-select checkmark that is shown by default and suppressible via `hideSingleSelectionIndicator`. The shared-class design also couples MatOption's lifecycle to `MatSelect` via an injection token, rather than having the option be a standalone composable primitive.
anatomy[option-group] renamed `<mat-optgroup>` with `label: string` input and optional `disabled: boolean` input. When `disabled="true"` the entire group and all its child `<mat-option>` elements are disabled. The group label is rendered as a non-selectable `<span>` styled by Material typography. Role: the `<mat-optgroup>` host element carries no ARIA role itself; child `<mat-option>` elements each carry `role="option"` and the listbox carries `role="listbox"` — the group is semantically invisible to AT in current Material output. Source: github.com/angular/components blob src/material/core/option/optgroup.ts (fetched 2026-05-04). Canonical `option-group` maps to `<div role="group" aria-labelledby="...">`. `<mat-optgroup>` does not emit `role="group"` on the host — it relies on the visually styled label text and the `<mat-option>` children having `role="option"` to convey grouping. This is a known a11y gap: screen readers do not announce the group name when navigating options because the grouping role and aria-labelledby are absent. The canonical recommends `role="group"` + `aria-labelledby`; Material diverges here for legacy rendering reasons.
axes.properties[native] omitted MatSelect has no `native` input and does not offer a native `<select>` fallback. It is always a custom listbox. Angular applications targeting mobile are expected to rely on the custom implementation — there is no automatic switch to the platform picker at small viewports. Consumers who want a native `<select>` must use a plain HTML element with MatFormField's `matNativeControl` directive (`<select matNativeControl>`), which is a separate directive family not part of MatSelect. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04); no `native` input exists.
axes.properties[size] omitted MatSelect has no `size` input (sm / md / lg). Visual density and field height are governed by `MatFormField`'s `appearance` input (`'fill' | 'outline'`) and the global `MAT_FORM_FIELD_DEFAULT_OPTIONS` density setting. Consumers set density at the theme level (`density: -1 | -2 | -3` in the Angular Material theme) rather than per-instance. This is a design-system-level concern in Material, not a component prop. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04); no `size` or `density` input on MatSelect.
axes.variants reshaped No `variant` input on `<mat-select>`. The canonical `default` and `inline` variants are handled by the parent `<mat-form-field appearance="fill|outline">`. MatFormField's `appearance` input controls the field chrome (label animation, border style, background fill) and is independent of MatSelect. There is no inline (no-form-field) variant in the sense of a bare trigger without a MatFormField wrapper — though consumers can use `<mat-select>` without MatFormField, the component is designed and themed assuming MatFormField as the layout host. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04); no `variant` input. Canonical variants (`default` / `inline`) are select-level properties. Material delegates visual variant control to `MatFormField`, separating field-chrome concerns from the select component. This layering keeps MatSelect focused on selection behaviour while MatFormField handles label, error, hint, and prefix/suffix layout. The result is that consumers must know about two cooperating components to achieve the canonical default variant, rather than setting a single `variant` prop.
events[openChange] reshaped Three outputs on `<mat-select>`: `openedChange: EventEmitter<boolean>` — fires after the panel animation completes (true = opened, false = closed), matching the canonical boolean contract. `opened: Observable<void>` — fires when the panel opens (void payload). `closed: Observable<void>` — fires when the panel closes (void payload). Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04). Canonical `openChange` is one boolean output. MatSelect ships three outputs: `openedChange` matches the canonical payload (boolean, post-animation) but uses a different name; `opened` and `closed` are split void-payload edges for consumers who want fine-grained lifecycle hooks without maintaining a boolean themselves. This mirrors the same split in MatDrawer.
events[selectionChange] reshaped `selectionChange: EventEmitter<MatSelectChange>` where `MatSelectChange = { source: MatSelect; value: any }`. Consumers access the committed value via `event.value`; the `source` reference provides access to the full MatSelect instance (current `options`, `selected`, etc.). A companion `valueChange: EventEmitter<any>` emits only the raw value without the source reference. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04). Canonical `selectionChange` emits the flat selected value (T | T[] | null). MatSelect wraps it in `MatSelectChange`, a rich object carrying the source reference — the same pattern as MatAutocomplete's `optionSelected`. The richer payload is useful when multiple select instances share a handler and the consumer needs to identify the source. `valueChange` provides the flat payload as a convenience but is not the primary output name.
events[invalidChange] omitted No `invalidChange` output exists on MatSelect. Validation error state is managed by `MatFormField` via Angular's `ErrorStateMatcher` interface — the field's error display is driven by the associated `FormControl`'s `invalid` + `touched` status. MatSelect exposes `errorStateMatcher` as a per-instance override, but the invalid state is not surfaced as an output event. Consumers who need to react to validity changes subscribe to `FormControl.statusChanges` observable directly. Source: github.com/angular/components blob src/material/select/select.ts (fetched 2026-05-04); no `invalidChange` output declared.
Why this audit reads the way it does

Angular Material MatSelect is a purpose-built custom-listbox select that differs from the canonical in four primary ways: 1. Trigger element is a <div role="combobox">, not a <button>. The full ARIA contract (aria-expanded, aria-controls, aria-activedescendant, aria-haspopup) is present; the host element type alone diverges. 2. Panel is auto-created, not consumer-authored. Consumers project <mat-option> and <mat-optgroup> children; MatSelect routes them into an internal CDK Overlay panel. Consumer control over panel structure is limited to panelClass CSS and the projected option/group content. 3. No native fallback. MatSelect is always a custom listbox; native <select> rendering is a separate matNativeControl directive path. The canonical native property and the responsive breakpoint.sm switch-to-native behaviour are both absent. 4. variant and size are out-of-scope for MatSelect — both are delegated to MatFormField (appearance) and the Angular Material theming layer (density), requiring consumers to configure two cooperating components. Material strengths relative to canonical: 1. MatSelectTrigger enables full-content trigger override without losing the CDK Overlay and keyboard contract — a genuine extension beyond canonical value-display. 2. MatPseudoCheckbox gives multi-select a first-class visual affordance without native checkbox semantics polluting the listbox. 3. hideSingleSelectionIndicator and disableOptionCentering are production-grade ergonomic controls absent from the canonical. 4. compareWith input handles object-valued options correctly without consumer serialisation — essential for FormControl<T> where T is not a primitive. 5. canSelectNullableOptions allows null/undefined values in the option list, covering the "clear selection" affordance inline without a separate trigger.

headlessui Listbox / ListboxButton / ListboxOptions / ListboxOption / ListboxSelectedOption
import { useState } from 'react';
import {
Listbox, ListboxButton,
ListboxOptions, ListboxOption,
} from '@headlessui/react';
const statuses = [
{ id: 'draft', label: 'Draft' },
{ id: 'review', label: 'In Review' },
{ id: 'published', label: 'Published' },
{ id: 'archived', label: 'Archived' },
];
export function StatusSelect() {
const [selected, setSelected] = useState(statuses[0]);
return (
<Listbox value={selected} onChange={setSelected} name="status">
<ListboxButton className="w-full flex items-center justify-between border rounded px-3 py-2 data-open:ring-2">
{selected.label}
<span aria-hidden="true">&#8964;</span>
</ListboxButton>
<ListboxOptions
anchor="bottom start"
className="w-[var(--button-width)] rounded shadow-lg bg-white empty:hidden"
>
{statuses.map((s) => (
<ListboxOption
key={s.id}
value={s}
className="px-3 py-2 cursor-default data-focus:bg-blue-100 data-selected:font-semibold"
>
{s.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
);
}

Divergence

From Type → To Rationale
anatomy[trigger] reshaped Headless UI ships TWO primitives for the canonical select pattern: `Listbox` (custom-styled, arbitrary option content, full APG keyboard contract) and `Select` (thin wrapper around native `<select>` with accessibility state attributes but no custom popup). The canonical `select` unifies both paths via the `native: boolean` property; Headless UI separates them as entirely distinct components with incompatible APIs. The canonical `native` property is a runtime toggle on a single component — one API surface, two rendering paths. Headless UI instead ships `Listbox` and `Select` as separate component families with no shared root. Consumers who want the mobile-native fallback must maintain two completely separate component trees or author their own abstraction layer. The split reflects Headless UI's philosophy of minimal surface per primitive over unified component APIs. Source: https://headlessui.com/react/listbox and https://headlessui.com/react/select (v2.1, 2026-05-04).
anatomy[value-display] reshaped Render prop `value` on `ListboxButton` children, or the dedicated `ListboxSelectedOption` component for a "buttonless" composition pattern The canonical `value-display` is a discrete named slot that renders the current selection text. Headless UI exposes the selected value via a render prop (`value`) on `ListboxButton` children, leaving the consumer to place the text node. `ListboxSelectedOption` is a separate component that exposes a `selectedOption` render prop for more complex display scenarios. There is no discrete `value-display` component — the display is composed by the consumer inside `ListboxButton`. Source: https://headlessui.com/react/listbox (ListboxButton render props + ListboxSelectedOption, v2.1, 2026-05-04).
anatomy[caret] omitted Headless UI ships no caret component. The canonical `caret` slot is a decorative chevron inside the trigger that rotates on open state. Headless UI exposes the `data-open` / `data-closed` state attribute on `ListboxButton` for consumer CSS (e.g. `data-open:rotate-180`) but provides no chevron element — consumers must author their own icon node inside `ListboxButton` children and bind rotation via CSS or the `open` render prop. Source: https://headlessui.com/react/listbox (ListboxButton data attributes, v2.1, 2026-05-04).
anatomy[listbox] renamed ListboxOptions Canonical `listbox` slot maps to `role="listbox"`. Headless UI names the component `ListboxOptions`; it renders as a `<div>` by default and injects `role="listbox"` internally. The divergence is at API naming; the ARIA contract is honoured. Source: https://headlessui.com/react/listbox (ListboxOptions props, v2.1, 2026-05-04).
anatomy[option] renamed ListboxOption Canonical `option` slot expects `role="option"`. `ListboxOption` renders as a `<div>` by default and applies `role="option"` plus a unique `id` internally. `aria-selected` is managed automatically. The rename is API-level; the ARIA contract matches canonical. Source: https://headlessui.com/react/listbox (ListboxOption props, v2.1, 2026-05-04).
anatomy[option-group] omitted Headless UI ships no `ListboxOptionGroup` component. The canonical `option-group` slot maps to `role="group"` with `aria-labelledby`. Consumers who need option groups must author a plain `<div role="group">` with a label inside `ListboxOptions` — there is no first-class grouping primitive. Source: https://headlessui.com/react/listbox (v2.1, 2026-05-04).
axes.properties[native] omitted The canonical `native` boolean toggles native `<select>` rendering within a single component. In Headless UI this path is a separate component entirely (`Select`, not `Listbox`) with an incompatible API. There is no `native` prop on `Listbox`. Consumers wanting native rendering at `breakpoint.sm` must conditionally render `<Select>` or a raw `<select>` — the canonical responsive fallback cannot be achieved within one component tree. Source: https://headlessui.com/react/select (v2.1, 2026-05-04).
axes.variants[inline] omitted Headless UI ships no `inline` variant (a select that renders its options inline rather than in a floating popup). The library is unstyled and layout-agnostic; no variant axis is exposed. Consumers who need an inline list pattern must compose it from `Listbox` with `static` on `ListboxOptions` (which skips portal/floating positioning), but no variant prop exists. Source: https://headlessui.com/react/listbox (ListboxOptions — static prop, v2.1, 2026-05-04).
events[selectionChange] renamed onChange on Listbox root Canonical `selectionChange` event. Headless UI places `onChange` on the `Listbox` root. In single-select mode it receives `T`; in multi-select (`multiple` prop) it receives `T[]`. The payload matches canonical intent but the event name differs. Source: https://headlessui.com/react/listbox (Listbox props — onChange, v2.1, 2026-05-04).
events[openChange] reshaped No open/close event callback; open state available via render prop `open` on ListboxButton children Canonical `openChange` fires on both edges (open → true, close → false). Headless UI exposes no open/close event callback on `Listbox`. Consumers who need to react to the open/close transition must read the `open` render prop from `ListboxButton` children or drive their own controlled state. Source: https://headlessui.com/react/listbox (Listbox props, v2.1, 2026-05-04).
axes.properties[multi] extended + `by` prop on `Listbox` root accepts a string field name or a `(a: T, b: T) => boolean` comparator function. When `value` is a structured object rather than a primitive, `by` controls how selected state is determined across renders. The canonical anatomy assumes primitive (string/number) values and has no equivalent comparator concept. The canonical `multi` property covers multi-select toggle behaviour but assumes primitive values for identity comparison. Headless UI's generic type parameter `T` allows any object as list values, requiring an explicit comparator to avoid false-negative identity checks when objects are re-created across renders. This is an extension beyond canonical scope. Source: https://headlessui.com/react/listbox (Listbox props — by, v2.1, 2026-05-04).
anatomy[listbox] extended + `anchor` prop on `ListboxOptions` drives built-in floating positioning (`"bottom start"`, `"top end"`, etc.). When `anchor` is set, the panel is rendered into a portal automatically. CSS custom properties `--button-width`, `--anchor-gap`, `--anchor-offset`, and `--anchor-padding` are injected for consumer sizing. The canonical anatomy documents portal-mounting as a requirement but prescribes no specific positioning API. Headless UI ships a first-party positioning system (backed by Floating UI) to remove the need for consumers to wire a third-party library. This is an extension beyond canonical scope. Source: https://headlessui.com/react/listbox (ListboxOptions props — anchor, portal, v2.1, 2026-05-04).
Why this audit reads the way it does

Headless UI React v2.1 does not ship a single "Select" primitive. The canonical select pattern is covered by two components with incompatible APIs: `Listbox` (custom-styled, full APG keyboard contract, arbitrary option content) and `Select` (thin native `<select>` wrapper with data attributes for styling). This audit covers `Listbox` as the primary match for the canonical anatomy; `Select` is documented as a divergence. Divergences cluster into three groups: 1. Two-primitive split — the canonical `native: boolean` toggle is not a prop; it requires switching to an entirely different component family. The canonical responsive fallback at breakpoint.sm (switch to native) cannot be achieved within one component tree. 2. Missing slots — no caret component, no option-group primitive, no value-display component. All three require consumer-authored DOM inside Listbox primitives. Consumers carry the authoring burden for these common sub-parts. 3. API naming/shape differences — `onChange` instead of `selectionChange`; no `openChange` event (open state is a render prop); `ListboxOptions` and `ListboxOption` instead of `listbox` and `option` slot names. The ARIA contract (role="listbox", role="option", aria-selected, aria-expanded, aria-haspopup, focus staying on the button via aria-activedescendant) is correctly implemented and matches canonical requirements. Typeahead by first letter (A–Z keyboard navigation) is documented and implemented.

radix Select
import * as Select from '@radix-ui/react-select';
import { ChevronDown, ChevronUp, Check } from 'lucide-react';
function MySelect({ value, onChange }) {
return (
<Select.Root value={value} onValueChange={onChange} name="status">
<Select.Trigger aria-label="Select status">
<Select.Value placeholder="Choose a status…" />
<Select.Icon>
<ChevronDown />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content position="popper" sideOffset={4}>
<Select.ScrollUpButton>
<ChevronUp />
</Select.ScrollUpButton>
<Select.Viewport>
<Select.Group>
<Select.Label>Active</Select.Label>
<Select.Item value="draft">
<Select.ItemText>Draft</Select.ItemText>
<Select.ItemIndicator>
<Check />
</Select.ItemIndicator>
</Select.Item>
<Select.Item value="published">
<Select.ItemText>Published</Select.ItemText>
<Select.ItemIndicator>
<Check />
</Select.ItemIndicator>
</Select.Item>
<Select.Separator />
<Select.Item value="archived" disabled>
<Select.ItemText>Archived</Select.ItemText>
<Select.ItemIndicator>
<Check />
</Select.ItemIndicator>
</Select.Item>
</Select.Group>
</Select.Viewport>
<Select.ScrollDownButton>
<ChevronDown />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}

Divergence

From Type → To Rationale
anatomy[value-display] renamed Select.Value Functional match for the canonical value-display slot. Radix names it "Value" following its own sub-component vocabulary. It accepts a `placeholder` prop (ReactNode) that is shown when no item is selected, matching the canonical fallback contract. Renders the selected item's text content automatically from the active Item's ItemText child. Source: https://www.radix-ui.com/primitives/docs/components/select#value (verified 2026-05-04)
anatomy[caret] renamed Select.Icon Radix exposes a dedicated `Select.Icon` sub-component as the wrapper for the trigger's chevron glyph. It adds `aria-hidden="true"` automatically, matching the canonical decorative contract. The glyph itself (SVG or icon component) is passed as a child — Radix ships a default ChevronDown via the Icon sub-component when no children are provided. Source: https://www.radix-ui.com/primitives/docs/components/select#icon (verified 2026-05-04)
anatomy[listbox] reshaped Select.Portal > Select.Content > Select.Viewport. The canonical listbox is a single floating semantic unit. Radix splits it into three nested layers: Portal handles document-root mounting, Content handles positioning and focus management, and Viewport is the scroll window that carries `role="listbox"`. Radix separates portal mounting (Portal), positioning + event handling (Content), and the scroll-capable item-list surface (Viewport) for independent composability. The canonical listbox role ends up on Viewport, not Content. This is the structural split that enables the ScrollUpButton / ScrollDownButton affordances to live adjacent to Viewport inside Content without disrupting the listbox semantic boundary. Source: https://www.radix-ui.com/primitives/docs/components/select#content and #viewport (verified 2026-05-04)
anatomy[option] renamed Select.Item (with Select.ItemText child for the label surface) Radix names the selectable unit "Item" rather than "Option". Item sets `role="option"` on its root element and applies `data-state="checked"` (not `aria-selected`) for the selected item. The visible label text must be placed inside a `Select.ItemText` child — this indirection lets Radix extract the text for the Value display and for typeahead without duplicating DOM content. Omitting ItemText causes the Value display to show nothing. Source: https://www.radix-ui.com/primitives/docs/components/select#item (verified 2026-05-04)
anatomy[option-group] renamed Select.Group (with Select.Label sibling for the group heading) One-to-one functional match. Radix names it "Group" and requires a sibling `Select.Label` for the non-selectable heading text (rather than an aria-labelledby wiring the consumer must supply). Radix auto-connects the Label to the Group via an internal `aria-labelledby` id pair, matching the canonical `role="group"` + `aria-labelledby` contract without manual id management. Source: https://www.radix-ui.com/primitives/docs/components/select#group (verified 2026-05-04)
anatomy[listbox] extended + Select.ScrollUpButton and Select.ScrollDownButton — decorative scroll affordance buttons rendered above and below the Viewport when the item list overflows the Content height. They auto-show/hide based on scroll position and scroll the Viewport on pointer-down. No canonical anatomy slot exists for this pattern. Radix's item-aligned positioning mode constrains the Content height to avoid viewport overflow rather than letting the Viewport scroll freely. The ScrollUpButton / ScrollDownButton pattern provides a discoverable scroll affordance in this constrained space — a UX detail required when the item list is taller than the viewport permits. The canonical listbox slot assumes unrestricted height or standard browser scrollbar; Radix uniquely needs this extension for its default item-aligned mode. Source: https://www.radix-ui.com/primitives/docs/components/select#scrollupbutton (verified 2026-05-04)
anatomy[option] extended + Select.ItemIndicator — a sub-component that renders only when the parent Item is selected (`data-state="checked"`). Used for the selected-state checkmark glyph. Canonical anatomy has no named slot for this affordance (the option slot description notes a "visual check" for multi-select but does not define a dedicated sub-component). Radix isolates the selected-state indicator into a controlled sub-component so that consumers do not need to read `data-state` themselves to conditionally render a checkmark. This is a Radix composition idiom (mirrors Dialog.Close / Popover.Close pattern) that has no canonical equivalent slot. Source: https://www.radix-ui.com/primitives/docs/components/select#itemindicator (verified 2026-05-04)
axes.properties[multi] omitted Radix Select is single-select only. There is no `multiple` prop on Select.Root, no multi-selection keyboard contract, and no mechanism for accumulating an array of selected values. The canonical `multi: boolean` property and its associated `aria-multiselectable` and value-array payload are entirely absent. Consumers requiring multi-select must reach for a different primitive (e.g. a hand-rolled listbox or a third-party library). Source: https://www.radix-ui.com/primitives/docs/components/select#root (verified 2026-05-04; no `multiple` prop documented)
axes.properties[native] omitted Radix Select is always a fully custom listbox implementation; there is no `native` prop to fall back to a platform `<select>` picker. Radix renders a hidden `<select>` when the `name` prop is supplied (for form participation), but this is invisible — the visible UI is always the custom component. The canonical `native: boolean` axis (which switches the visible rendering to the OS picker on mobile / breakpoint.sm) does not exist. Source: https://www.radix-ui.com/primitives/docs/components/select#root (verified 2026-05-04; no `native` prop documented)
anatomy[listbox] extended + Select.Content `position` prop: `'item-aligned'` (default) positions the Content so the active item overlaps the trigger button — replicating native OS select behaviour; `'popper'` positions the Content like a Popover, below (or above) the trigger with configurable `side`, `sideOffset`, `align`, and `alignOffset` props plus CSS custom properties (`--radix-select-trigger-width`, `--radix-select-content-available-height`). The canonical listbox slot specifies a single positioning model (floating below the trigger, anchored to its inline-start edge). Radix ships two models as a first-class enum to serve both the OS-style dropdown pattern (item-aligned) and the design-system dropdown pattern (popper). The item-aligned default is unusual among headless libraries and has no canonical equivalent — consumers often switch to `position="popper"` to match the canonical positioning contract. Source: https://www.radix-ui.com/primitives/docs/components/select#content (verified 2026-05-04)
Why this audit reads the way it does

Radix Select (`@radix-ui/react-select`) is a first-class, fully-implemented primitive — unlike Radix's absent Combobox. It correctly covers the APG Select-Only Combobox / Listbox pattern: `role="combobox"` on the trigger, `role="listbox"` on the Viewport, `role="option"` on each Item, typeahead by first-letter, and full keyboard navigation (Space/Enter/Arrow/Escape/Home/End). Form participation is handled via a hidden native `<select>` rendered when the `name` prop is supplied, matching the canonical form-submission contract for single-select without the consumer needing to wire ElementInternals. The primary structural divergence is the three-layer listbox split (Portal / Content / Viewport) versus the canonical single listbox slot, driven by Radix's need to separate portal mounting, positioning, and the scroll-window. The two supplementary extensions — ScrollUpButton/ScrollDownButton and ItemIndicator — are Radix-specific UX additions with no canonical equivalents. The two significant omissions are multi-select (Radix is single-select only) and the native-select fallback (canonical `native: boolean` axis). Both are intentional scope decisions by the Radix authors: multi-select requires a different ARIA pattern (Listbox rather than Select-Only Combobox) and Radix has chosen not to ship it under the Select primitive.

Designer

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
Designer

Token usage per slot

trigger
spacing
  • paddingspacing.compact
  • gapspacing.compact
radius
  • cornerradius.md
color
  • backgroundcolor.surface.bg
  • foregroundcolor.text.primary
  • bordercolor.border.strong
  • ringcolor.border.focus
typography
  • sizetext.md
value-display
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
  • lineHeightleading.snug
caret
color
  • foregroundcolor.text.muted
listbox
spacing
  • paddingspacing.tight
radius
  • cornerradius.md
color
  • backgroundcolor.surface.raised
  • bordercolor.border.subtle
elevation
  • shadowelevation.lg
option
spacing
  • paddingspacing.compact
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps default / inline.
SizeEnumsizesm / md / lg.
MultiBooleanmultiToggles single-vs-multi-select. Multi-select trigger displays a count by canon; comma-separated list is anti-pattern.
RequiredBooleanrequiredDrives HTML5 validation on submit; canonical inline-error pattern via `aria-invalid` plus `aria-describedby`.
NativeBooleannativeToggles 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 GroupsBooleangroupsToggles the option-group slot. Use for long lists categorised by domain (countries by continent, files by folder).
DisabledBooleandisabledDisables the entire select. Disabled options are a separate concern (per-option Boolean).
PlaceholderTextplaceholderShown in value-display when nothing is selected. SR users hear it as the trigger's accessible name fallback.
CaretSlotcaretSwap the chevron glyph; defaults vary per design system (down-arrow, triangle, double-arrow).
Designer

Motion

TransitionDuration token
openmotion.duration.fast
closemotion.duration.instant
Easing
motion.easing.decelerate
Reduced motion
Instant (jump cut)
Designer

Responsive behaviour

BreakpointChange
breakpoint.smAt 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.mdAbove this width, custom listbox renders as authored. Visual fidelity matches the design system; keyboard contract follows APG.
Both

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.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

default inline

Properties

The same component, parameterised.

PropertyType
multi boolean
required boolean
size sm | md | lg
native boolean

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.
Both

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. The same convention applies to `Combobox` on small viewports (see Combobox responsive at breakpoint.sm) — count-display is canon at every width, comma-separated lists are not a fallback.

  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).

Both

Contracts

Non-negotiable contracts

  1. APGAPG: Listbox / Combobox-with-Listbox

    Select implements typeahead-by-first-letter — typing a character on the focused trigger jumps to and selects the next option whose label starts with that character.

    The most-overlooked APG behaviour for this pattern. Without it, keyboard-only users must arrow through the full list to select, which scales poorly past ~10 options and breaks the canonical keyboard discoverability flow.

Vocabulary drift

APG
Listbox / Combobox without textbox
APG uses different names for the variants.
HTML
native `<select>`
Platform-rendered picker; the `native: true` variant.
Vue Headless UI
Listbox
Designer

Common mistakes

Blocker

#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.

Blocker

#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`.

Major

#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.

Minor

#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".

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.