Skip to documentation content

Select

Choose one value from a known list in a compact popup.

select-demo

Overview

Select lets people choose exactly one value from a closed list without spending page space showing every option. The trigger shows the current choice; the popup holds the full set.

Choosing between the value controls is a real decision, so here is the whole map:

  • 2–5 options where seeing all of them helps — use Radio Group. Radio buttons show every choice at once and make the current state visible without interaction.
  • One value from a short-to-medium closed list — Select. This component.
  • A long or searchable list — Combobox. It filters as people type.
  • Values that do not exist on any list yet — Tags Input or Autocomplete. A select only ever accepts what you gave it.
  • Running commands (open, delete, navigate) — Menu, not Select. A select chooses values; it does not perform actions.
  • On/off states — Switch. "Enabled / Disabled" as two select options forces an extra click to learn what is already knowable.

Anatomy

A select has a trigger (the closed control showing the current value), a popup listing options, and optionally grouped options with labels. The trigger's label belongs above it as static text; see Don't do this.

The trigger sizes itself to its content, so long values widen it deliberately rather than truncating silently — give it a className width when it lives in a fixed layout.

Behavior

Keyboard.

KeyResult
Enter / Space / ArrowDownOpen the popup
ArrowUp / ArrowDownMove between options
Character keysTypeahead — jump to options starting with those letters
EnterChoose the highlighted option and close
EscapeClose without changing the value
TabClose the popup and leave the control

Opening never commits. Browsing options with the keyboard changes the highlight, not the value. The value changes only on explicit selection — someone can open the popup to look, press Escape, and their original setting is intact.

Grouping. Use SelectGroup with SelectGroupLabel past about ten options. Group labels are announced by screen readers, giving structure that a flat list of twenty items lacks.

Multiple values. Select handles a multiple mode for compact tag-style choices; for anything user-facing where each selection deserves visibility, prefer MultiSelect from the Product family.

States

Disabled selects are skipped by keyboard focus and excluded from submission. Read-only data that must submit has no place in a select at all — render text instead; a control that cannot be changed should not look changeable.

Invalid. Setting aria-invalid="true" switches the trigger border to the danger color and announces the state to assistive technology. Pair it with an error message tied via aria-describedby that says what to choose and why.

Empty. An unchosen required select shows its placeholder ("Choose a region…"). That placeholder disappears the moment a value exists — which is correct for a trigger, and exactly why it cannot replace a label.

Accessibility

The trigger carries its accessible name from your visible label (Label + htmlFor, or a Field wrapper). Options are announced with their selected/checked state and group labels. The popup implements the standard listbox keyboard model shown above; focus returns to the trigger when it closes.

Typeahead matches from the start of option labels, so option copy matters for keyboard speed: "United States" is findable under U, "US-based customers" is not.

Colors come from tokens and adapt to both themes automatically — trigger, popup, and highlighted-option states included. The popup positions with collision handling and mirrors correctly in RTL, including typeahead matching against the option text as written. Option rows are sized for touch through the same spacing scale as other controls; very long option labels wrap inside the popup rather than overflowing.

Installation

npx honestui@latest add select

Usage

import { Label } from "@/components/ui/label";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
<Select items={regions} defaultValue={null}>
  <SelectTrigger className="w-full">
    <SelectValue />
  </SelectTrigger>
  <SelectPopup>
    {regions.map((region) => (
      <SelectItem key={region.value} value={region.value}>
        {region.label}
      </SelectItem>
    ))}
  </SelectPopup>
</Select>

Don't do this

A binary choice behind a popup

// Bad
<Select items={[{ label: "Enabled", value: "on" }, { label: "Disabled", value: "off" }]} />
// Good
<Switch defaultChecked /> Notifications

Two options do not need opening anything. A switch shows its state from across the room; a collapsed select hides the answer until clicked, costs two interactions to change, and reads like there might be more options hiding in there.

The placeholder doing the label's job

// Bad
<Select items={sizes} defaultValue={null} />
// trigger shows "Choose size…" — until it doesn't
// Good
<Label htmlFor={id}>Size</Label>
<Select id-linked items={sizes} defaultValue={null} />

Once someone chooses, the trigger shows the value and "Choose size…" is gone forever. Anyone returning to the form — or filling in the third similar field in a row — can no longer tell what the control asks for. Screen readers also may not announce a placeholder as the field's name. Keep a permanent label.

Actions dressed up as values

// Bad
<Select items={[
  { label: "Duplicate project", value: "duplicate" },
  { label: "Delete project…", value: "delete" },
]} onChange={(value) => runAction(value)} />
// Good
<Menu>
  <MenuTrigger render={<Button variant="outline" />}>Project actions</MenuTrigger>
  <MenuPopup>{/* Duplicate, Delete… */}</MenuPopup>
</Menu>

A select answers "which value does this field hold?" — after choosing Delete, the field would claim to hold deletion, which is nonsense. Menus run commands and close; selects commit state. People also expect a select's popup to reopen showing their "choice", and an action breaks that model mid-flow.

Examples

Groups, alignment, and sizes

select-with-groups

Disabled

select-disabled

In a form

Validation wiring comes from Field — the select reports invalid state automatically.

select-form

Multiple values

select-multiple

API reference

Select accepts Base UI Select props plus Honest UI additions:

PropValuesDefault
items{ label: string; value: unknown }[]—
multiplebooleanfalse

Parts: SelectTrigger, SelectValue, SelectPopup, SelectItem, SelectGroup, SelectGroupLabel. Native attributes such as disabled, required, and name pass through; validation state follows aria-invalid.

See the Base UI Select API.