Select
A trigger that opens a floating list to choose one option from, built on Base UI Select and drawing only neutral chrome.
Preview
select · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/select
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Select } from "@/components/ui/select"<Select
label="Timezone"
placeholder="Choose a timezone"
value={timezone}
onValueChange={setTimezone}
options={[
{ value: "lisbon", label: "Lisbon" },
{ value: "berlin", label: "Berlin" },
{ value: "tokyo", label: "Tokyo" },
]}
/>When to use it
Use it when
- Choosing one value from a list too long to lay flat, such as a timezone.
- A single choice on a dense form, where a closed trigger keeps a long list from pushing fields down.
- A setting the reader changes rarely, so opening the list costs little.
Do not use it when
- Fewer than about seven options, easier scanned flat.Use
radio-groupinstead. - A clinical value with a range and a verdict, needing a control that formats it.Use
reading-inputinstead. - The options open different views, so choosing one swaps what the reader sees.Use
tabsinstead.
Anatomy
SelectThe root, rendered as the trigger button, the one tab stop.Controlled bylabelSelect.ValueThe chosen label, or the muted placeholder via data-placeholder.Controlled byplaceholderSelect.IconThe open indicator, a lucide ChevronsUpDown, aria-hidden.Select.PopupThe floating list, portalled past clipping, a raised card at trigger width.Select.ItemOne option row, role=option with aria-selected, neutral state-hover.Controlled byoptionsSelect.ItemIndicatorThe tick on the chosen option, a lucide Check, selection without colour.
- Select 1Trigger, role combobox, one tab stop
- Select.Value 1data-placeholder when unchosen
- Select.Icon 1ChevronsUpDown, aria-hidden
- Select.Popup 1Portalled, role listbox, raised
- Select.Item 1..nrole option, aria-selected, per option
- Select.ItemIndicator 0..1lucide Check, selected row
- Select.Item 1..nrole option, aria-selected, per option
Examples
Choosing from a list
options drives the list, here a set of fictional timezones. A tick and a
neutral highlight carry the selection, not a colour.
select-choosing-from-a-list · base base · style base-lyraOpen under the product themeWith a placeholder
placeholder fills the trigger before anything is chosen: a prompt, not a
value, and it never appears in the list.
select-with-a-placeholder · base base · style base-lyraOpen under the product themeContent guidelines
Keep option labels short, parallel and in sentence case, naming the value each sets, ordered the way the reader thinks rather than how the data is stored. Write the placeholder as a prompt, never as a plausible value.
Do
"Choose a timezone" as the placeholder, with parallel labels: Lisbon, Berlin, Tokyo.
Don’t
"Lisbon" as a placeholder while nothing is chosen. A default and a blank read alike.
Accessibility
Audited against WCAG 2.2 AA in a source pass and a rendered pass, author-run rather than independent, with clinical review still pending.
Base UI supplies a combobox trigger, a listbox popup and aria-selected
options; icons are aria-hidden, and selection is shown by a Check glyph and
aria-selected, not colour. The focus ring, the --opsin-target-minimum hit
area, neutral chrome colours and non-px type sizes all hold.
Two gaps remain. The required label becomes the trigger's aria-label, so
once a value fills the trigger a reader can lose its programmatic name, a gap
shared by several wrappers and waiting on one system-wide repair. At 200%
text a long value truncates in the trigger, with no loss of function since
the full label stays readable in the open list.
Contrast pairs and the popup under forced-colors: active are still unmeasured.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus onto the trigger | One tab stop; disabled selects are skipped. |
| Shift+Tab | Moves focus back off the trigger | Never enters the list. |
| Enter, Space, Arrow Down, Arrow Up | Opens the list from the trigger | Highlight lands on the chosen or first enabled option. |
| Arrow Down, Arrow Up | Moves the highlight through the open list | Disabled options are skipped. No wrapping. |
| Home, End | Moves the highlight to the first or last option | Both skip disabled options. |
| Enter | Chooses the highlighted option and closes the list | Focus returns to the trigger, showing the value. |
| Escape | Closes the list without choosing | The value is left as it was. |
| Type a letter | Jumps to the next option whose label starts with what was typed | Base UI type-to-find, unchecked against a screen reader. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from SelectProps in registry/bases/base/select.tsx.
value and onValueChange make this controlled: no internal selection state,
and a value matching no option shows the placeholder. label is required, so
the trigger always has an accessible name.
Related
- RadioGroup: every option at once, better under about seven options.
- SegmentedControl: sets a parameter of one view from a small flat set.
- ReadingInput: for a clinical value with a range and a meaning.