opsinjs
ComponentsActions and forms

Select

A trigger that opens a floating list to choose one option from, built on Base UI Select and drawing only neutral chrome.

Preview

Theme
Density
Text
Status
select · base base · style base-lyraOpen under the product theme

Installation

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-group instead.
  • A clinical value with a range and a verdict, needing a control that formats it.Use reading-input instead.
  • The options open different views, so choosing one swaps what the reader sees.Use tabs instead.

Anatomy

  1. SelectThe root, rendered as the trigger button, the one tab stop.Controlled by label
  2. Select.ValueThe chosen label, or the muted placeholder via data-placeholder.Controlled by placeholder
  3. Select.IconThe open indicator, a lucide ChevronsUpDown, aria-hidden.
  4. Select.PopupThe floating list, portalled past clipping, a raised card at trigger width.
  5. Select.ItemOne option row, role=option with aria-selected, neutral state-hover.Controlled by options
  6. Select.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

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.

Theme
Density
Text
Status
select-choosing-from-a-list · base base · style base-lyraOpen under the product theme

With a placeholder

placeholder fills the trigger before anything is chosen: a prompt, not a value, and it never appears in the list.

Theme
Density
Text
Status
select-with-a-placeholder · base base · style base-lyraOpen under the product theme

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

KeyActionNotes
TabMoves focus onto the triggerOne tab stop; disabled selects are skipped.
Shift+TabMoves focus back off the triggerNever enters the list.
Enter, Space, Arrow Down, Arrow UpOpens the list from the triggerHighlight lands on the chosen or first enabled option.
Arrow Down, Arrow UpMoves the highlight through the open listDisabled options are skipped. No wrapping.
Home, EndMoves the highlight to the first or last optionBoth skip disabled options.
EnterChooses the highlighted option and closes the listFocus returns to the trigger, showing the value.
EscapeCloses the list without choosingThe value is left as it was.
Type a letterJumps to the next option whose label starts with what was typedBase UI type-to-find, unchecked against a screen reader.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor
body text on the canvas materiallight104.717.96:1Pass
body text on the canvas materialdark-100.518.00:1Pass
body text on the inset materiallight98.116.32:1Pass
body text on the inset materialdark-100.117.18:1Pass
body text on the card materiallight104.717.96:1Pass
body text on the card materialdark-99.616.32:1Pass
body text on the raised materiallight104.717.96:1Pass
body text on the raised materialdark-97.513.77:1Pass
body text on the sheet material over the darkest backdroplight99.916.76:1Pass
body text on the sheet material over the darkest backdropdark-99.716.47:1Pass
body text on the sheet material over the lightest backdroplight104.717.96:1Pass
body text on the sheet material over the lightest backdropdark-80.86.07:1Pass
body text on the overlay material over the darkest backdroplight85.913.48:1Pass
body text on the overlay material over the darkest backdropdark-99.916.77:1Pass
body text on the overlay material over the lightest backdroplight104.717.96:1Pass
body text on the overlay material over the lightest backdropdark-54.22.69:1Below 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.

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

On this page