Component · Navigation & Feedback
Select
Overview
A form control that picks one value from a list of predefined options and shows the chosen
one in its trigger. It is a styled stand-in for a native <select> — reach for it only when the
trigger or the option rows need more than a native control allows (an icon per option, a grouped
list with headings, a two-line option). When plain text options in a system font are enough, a
native <select> is the lighter choice.
Select follows the WAI-ARIA APG
Select-Only Combobox
pattern: the trigger is role="combobox" with aria-haspopup="listbox", the open list is
role="listbox", and DOM focus never leaves the trigger — the option being explored is
pointed at with aria-activedescendant.
Select separates the active option (the one being explored) from the committed value (the one chosen), and supports typeahead — see Active option vs. committed value.
It is not a command menu. A button that runs actions is Dropdown Menu
(role="menu", wrapping arrow keys). Version 1 Select is single-value only — no multi-select.
Anatomy
Select (state: committed value, open/closed, ACTIVE option)
├─ SelectTrigger <button role="combobox">, styled like Input
│ ├─ SelectValue the chosen option's label, or the placeholder
│ └─ (chevron) ChevronDown, decorative
└─ SelectContent role="listbox", anchored to the trigger
├─ SelectGroup role="group" (aria-labelledby → a SelectLabel)
│ ├─ SelectLabel non-interactive group heading
│ ├─ SelectItem role="option", aria-selected, a check when chosen
│ └─ SelectItem …
├─ SelectSeparator role="separator"
└─ SelectItem …
Select— holds the committed value (uncontrolleddefaultValue, or controlledvalue+onValueChange), the open state, and the active option (a separate cursor — see Active option vs. committed value). Passnameto render a hidden input so the Select submits inside a plain<form>.disableddisables the whole control.SelectTrigger— the<button role="combobox">. Fixed appearance: styled to match Input (--scale-10tall,--inputborder,--radius-sm,--ringfocus ring, a--destructiveborder when you passaria-invalid), with a trailing chevron. Label it by pointingaria-labelledbyat your<Label>. It owns all keyboard handling, because focus stays here the whole time.SelectValue— renders the committed option's label. Passplaceholderfor the--muted-foregroundtext shown while nothing is chosen. It reads the label from theSelectItemchildren at render time (not from the open list, which is unmounted while closed), so everySelectItemmust be composed directly insideSelectContent/SelectGroup— not wrapped in a caller component — for its label to resolve.SelectContent— therole="listbox"surface. Not rendered while closed (like Popover / Dialog). Anchored below the trigger, at least as wide as it,max-h-[60vh]then scrolls. Give it anaria-label(oraria-labelledby) so the list is named.SelectItem— one option.role="option"witharia-selected; a leading check appears on the chosen one.valueis required (a simple string — it becomes part of anid).childrenis the display text; passlabelwhenchildrenis not plain text (it doubles as the option's accessible name).disabledmarks an option unavailable — skipped by the arrow keys and typeahead, the way a native<select>skips a disabled<option>.SelectLabel/SelectGroup/SelectSeparator— a group heading, itsrole="group"wrapper (point itsaria-labelledbyat the label'sid), and a hairline rule.
Live Example
Nothing committed yet.
Code Example
"use client";
import * as React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/**
* Live Select example — a styled single-value picker following the WAI-ARIA
* APG "Select-Only Combobox" pattern. `"use client"` so the demo can echo the
* committed value; the Select keeps DOM focus on the trigger the whole time
* and points at the option being explored with `aria-activedescendant`.
*
* - Arrow keys / typeahead move the ACTIVE option only. The value commits on
* `Enter` / `Space` / a click / `Tab`; `Escape` closes and keeps the
* previous value.
* - Arrow keys do NOT wrap at the ends (native `<select>` parity — the
* opposite of Dropdown Menu).
* - The grouped example ships a disabled option ("Miso") — it is skipped by
* arrow keys and typeahead, the way a native `<select>` skips a disabled
* `<option>` — and a preselected `defaultValue`.
*/
export function SelectShowcase() {
const [fruit, setFruit] = React.useState<string>("");
return (
<div className="flex max-w-xs flex-col gap-8">
<section className="flex flex-col gap-1.5">
<Label id="fruit-label" htmlFor="fruit-trigger">
Favourite fruit
</Label>
<Select value={fruit} onValueChange={setFruit}>
<SelectTrigger id="fruit-trigger" aria-labelledby="fruit-label">
<SelectValue placeholder="Pick one…" />
</SelectTrigger>
<SelectContent aria-label="Favourite fruit">
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="apricot">Apricot</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grapefruit">Grapefruit</SelectItem>
</SelectContent>
</Select>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{fruit ? `Committed: ${fruit}` : "Nothing committed yet."}
</p>
</section>
<section className="flex flex-col gap-1.5">
<Label id="soup-label" htmlFor="soup-trigger">
Soup of the day
</Label>
<Select defaultValue="tomato">
<SelectTrigger id="soup-trigger" aria-labelledby="soup-label">
<SelectValue placeholder="Choose a soup…" />
</SelectTrigger>
<SelectContent aria-label="Soup of the day">
<SelectGroup aria-labelledby="soup-cold">
<SelectLabel id="soup-cold">Cold</SelectLabel>
<SelectItem value="gazpacho">Gazpacho</SelectItem>
<SelectItem value="vichyssoise">Vichyssoise</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup aria-labelledby="soup-hot">
<SelectLabel id="soup-hot">Hot</SelectLabel>
<SelectItem value="tomato">Tomato</SelectItem>
<SelectItem value="ramen">Ramen</SelectItem>
<SelectItem value="miso" disabled>
Miso (sold out)
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</section>
</div>
);
}Variants
None. One trigger style, one list style, one option style. Width is the only thing that
varies, and it is caller-controlled — put a max-w-* (or a fixed width) on the element wrapping
<Select>. The list always renders at least as wide as the trigger.
States
Trigger
| State | Look |
|---|---|
| Resting | --background fill, --input border, --foreground value text (or --muted-foreground placeholder), --radius-sm |
| Focus (keyboard) | 2px --ring ring, flush — the same geometry as Input |
| Open | Same as focus; aria-expanded="true", aria-activedescendant set |
| Invalid | --destructive border when aria-invalid is set; the ring stays --ring, so "focused" and "invalid" read independently (matches Input) |
| Disabled | opacity-50, not-allowed cursor, native disabled — out of the tab order |
Option
| State | Look |
|---|---|
| Resting | --popover-foreground text, transparent background |
| Active (arrow / typeahead cursor, or pointer hover) | --accent background + --accent-foreground text — mouseenter moves the active cursor onto the option so pointer and keyboard agree |
| Selected (the committed value) | A leading --scale-4 check glyph; aria-selected="true" |
| Disabled | opacity-50, aria-disabled="true"; skipped by the arrow keys and typeahead |
List
| State | Look / behaviour |
|---|---|
| Closed | Not rendered |
| Open | --popover surface, --border edge, --shadow-md, --radius-lg, --scale-1 padding; min-width = trigger width; max-h-[60vh], then scrolls; the active option is kept scrolled into view. Anchored below the trigger; flips above / shifts to end-aligned on viewport collision |
Usage Guidance
Active option vs. committed value
These are two different things, and keeping them separate is what lets Escape undo an
exploration:
- The committed value is the real answer. It changes only on an explicit commit —
Enter/Space, a click on an option, orTab. Committing firesonValueChangeand closes the list. - The active option is a cursor. The arrow keys and typeahead move it and nothing else.
aria-activedescendanton the trigger points at it. It exists only while the list is open. Escapecloses the list and throws the active option away — the committed value is untouched.
When the list opens, the active option starts on the committed value (or the first option if nothing is committed yet).
Tokens
Select uses the --popover surface family that Popover and Dropdown Menu use for the list, and the same --input / --ring / --destructive treatment Input uses for the trigger; it introduces no component-specific styling values.
Implementation note: it reuses Popover's anchoring and dismissal pattern (a shared-width wrapper, a flip/shift collision pass, close on Escape / outside-pointerdown / trigger-scrolled-offscreen) without sharing Popover's contract: this surface is role="listbox", never role="dialog".
| Token | Where used | Rationale |
|---|---|---|
--background / --foreground | Trigger fill + value text | Matches Input — a Select trigger is a field |
--input | Trigger border | The shared field-border token |
--ring | Trigger focus ring | The system-wide focus token, same as Input / Button |
--destructive | Trigger border on aria-invalid | Error signalling, identical to Input |
--muted-foreground | Placeholder text, SelectLabel | Color names this for quiet secondary text |
--popover / --popover-foreground | List surface fill + option text | The "menus, tooltips" pair — shared with Popover / Dropdown Menu |
--border | List edge, SelectSeparator | The system hairline |
--shadow-md | List elevation | Shadows names "dropdowns, popovers" |
--radius-lg | List corner | Radius names "cards, popovers" |
--radius-sm | Trigger corner, option corner | Radius names "tags, inputs" — the trigger is an input |
--accent / --accent-foreground | Active option background + text | Color names --accent for "selected rows" |
--scale-10 (h-10) | Trigger height | Button / Input's default height step, so a Select lines up in a form row |
--scale-3 (px-3) | Trigger inline padding | Matches Input |
--scale-1 (p-1) | List padding | A tight 4px frame — matches Dropdown Menu |
--scale-2 / --scale-1-5 | Option padding | The same row rhythm as Dropdown Menu |
--scale-4 (size-4) | Chevron + check glyph size | Icons' inline size |
Text recipe: trigger + options text-sm; the trigger uses leading-normal (read value, like
Input), options leading-snug.
Do / Don’t
Do
- Label every Select with a visible
<Label>and wire it witharia-labelledbyon the trigger. - Give
SelectContentanaria-label(oraria-labelledby) so the list is named too. - Keep option labels short and parallel ("Small" / "Medium" / "Large", not "Small" / "A medium one" / "The large size").
- Use
defaultValuewhen there is a sensible default; leave it unset (with aplaceholder) when the user genuinely must choose — the same safe-default reasoning Radio Group applies. - Constrain the width on a wrapper around
<Select>. - Pass
namewhen the Select lives in a real<form>you submit without JavaScript.
Don’t
- Don't use it for a yes/no — that's a Switch or a Checkbox.
- Don't use it for 2–4 options the user benefits from seeing all at once — that's a Radio Group.
- Don't use it to run commands — that's Dropdown Menu.
- Don't put interactive content inside an option. An option is a single selectable value.
- Don't rely on it for very long lists in Version 1 — there is no search box and no virtualisation.
Accessibility
-
Roles: trigger is
role="combobox"+aria-haspopup="listbox"+aria-expanded+aria-controls+aria-activedescendant; the list isrole="listbox"; options arerole="option"witharia-selectedon exactly one (or none, when nothing is committed). -
Focus model: DOM focus stays on the trigger the entire time the list is open. The user's position in the list is conveyed by
aria-activedescendantpointing at the active option'sid, and the active option is kept scrolled into view. Nothing inside the list is a tab stop. -
Keyboard:
Key List closed List open Enter/SpaceOpen the list Commit the active option, close, keep focus on the trigger ArrowDown/ArrowUpOpen the list Move the active option — does not wrap at the ends (native <select>parity, the opposite of Dropdown Menu)Home/End— Move the active option to the first / last Printable character Open the list and move the active option to the first match — it does not commit Typeahead: move the active option to the next match (buffer clears after ~500 ms) Tab/Shift+TabMove focus as normal Commit the active option, close, and let focus move on to the next / previous element Escape— Close the list; the committed value is unchanged -
Disabled options are
aria-disabled="true"and are skipped by the arrow keys and typeahead — a screen-reader user can still read them by other means, but they are not part of the active rotation, matching a native disabled<option>. -
Forms: passing
namerenders a hidden<input>carrying the committed value, so the Select submits in a plain form.required, constraint validation, and form-library adapters are not in Version 1. -
Non-modal: the page behind the open list is never marked
inert; focus is not trapped. -
Contrast: the trigger reuses Input's measured pairings unchanged.
--accent- foregroundon--accentis the same active-row pairing used across the system, clearing WCAG 2.1 AA in both themes.
Related Components / Patterns
- Radio Group — one choice from a short, visible list.
- Dropdown Menu — commands rather than values.
- Combobox / Command — a filterable list.