Skip to content
Monogem

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 (uncontrolled defaultValue, or controlled value + onValueChange), the open state, and the active option (a separate cursor — see Active option vs. committed value). Pass name to render a hidden input so the Select submits inside a plain <form>. disabled disables the whole control.
  • SelectTrigger — the <button role="combobox">. Fixed appearance: styled to match Input (--scale-10 tall, --input border, --radius-sm, --ring focus ring, a --destructive border when you pass aria-invalid), with a trailing chevron. Label it by pointing aria-labelledby at your <Label>. It owns all keyboard handling, because focus stays here the whole time.
  • SelectValue — renders the committed option's label. Pass placeholder for the --muted-foreground text shown while nothing is chosen. It reads the label from the SelectItem children at render time (not from the open list, which is unmounted while closed), so every SelectItem must be composed directly inside SelectContent / SelectGroup — not wrapped in a caller component — for its label to resolve.
  • SelectContent — the role="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 an aria-label (or aria-labelledby) so the list is named.
  • SelectItem — one option. role="option" with aria-selected; a leading check appears on the chosen one. value is required (a simple string — it becomes part of an id). children is the display text; pass label when children is not plain text (it doubles as the option's accessible name). disabled marks 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, its role="group" wrapper (point its aria-labelledby at the label's id), and a hairline rule.

Live Example

Nothing committed yet.

Code Example

select-showcase.tsx
"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

StateLook
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
OpenSame 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)
Disabledopacity-50, not-allowed cursor, native disabled — out of the tab order

Option

StateLook
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"
Disabledopacity-50, aria-disabled="true"; skipped by the arrow keys and typeahead

List

StateLook / behaviour
ClosedNot 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, or Tab. Committing fires onValueChange and closes the list.
  • The active option is a cursor. The arrow keys and typeahead move it and nothing else. aria-activedescendant on the trigger points at it. It exists only while the list is open.
  • Escape closes 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".

TokenWhere usedRationale
--background / --foregroundTrigger fill + value textMatches Input — a Select trigger is a field
--inputTrigger borderThe shared field-border token
--ringTrigger focus ringThe system-wide focus token, same as Input / Button
--destructiveTrigger border on aria-invalidError signalling, identical to Input
--muted-foregroundPlaceholder text, SelectLabelColor names this for quiet secondary text
--popover / --popover-foregroundList surface fill + option textThe "menus, tooltips" pair — shared with Popover / Dropdown Menu
--borderList edge, SelectSeparatorThe system hairline
--shadow-mdList elevationShadows names "dropdowns, popovers"
--radius-lgList cornerRadius names "cards, popovers"
--radius-smTrigger corner, option cornerRadius names "tags, inputs" — the trigger is an input
--accent / --accent-foregroundActive option background + textColor names --accent for "selected rows"
--scale-10 (h-10)Trigger heightButton / Input's default height step, so a Select lines up in a form row
--scale-3 (px-3)Trigger inline paddingMatches Input
--scale-1 (p-1)List paddingA tight 4px frame — matches Dropdown Menu
--scale-2 / --scale-1-5Option paddingThe same row rhythm as Dropdown Menu
--scale-4 (size-4)Chevron + check glyph sizeIcons' 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 with aria-labelledby on the trigger.
  • Give SelectContent an aria-label (or aria-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 defaultValue when there is a sensible default; leave it unset (with a placeholder) when the user genuinely must choose — the same safe-default reasoning Radio Group applies.
  • Constrain the width on a wrapper around <Select>.
  • Pass name when 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 is role="listbox"; options are role="option" with aria-selected on 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-activedescendant pointing at the active option's id, and the active option is kept scrolled into view. Nothing inside the list is a tab stop.

  • Keyboard:

    KeyList closedList open
    Enter / SpaceOpen the listCommit the active option, close, keep focus on the trigger
    ArrowDown / ArrowUpOpen the listMove 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 characterOpen the list and move the active option to the first match — it does not commitTypeahead: move the active option to the next match (buffer clears after ~500 ms)
    Tab / Shift+TabMove focus as normalCommit 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 name renders 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- foreground on --accent is the same active-row pairing used across the system, clearing WCAG 2.1 AA in both themes.

Related Components / Patterns