---
title: "Select"
description: "A trigger that opens a floating list to choose one option from, built on Base UI Select and drawing only neutral chrome."
url: "https://opsinjs.pensievelabs.org/components/select"
source: "https://opsinjs.pensievelabs.org/components/select.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["dropdown", "picker"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="select"
  status="shipped"
  questions="[
  &#x22;Nobody has heard this in a screen reader, so whether the open, move and choose sequence reads cleanly is unknown.&#x22;,
  &#x22;No contrast pair has been measured; the trigger ink, the popup ink, the placeholder and the hairline are argued, not checked.&#x22;,
  &#x22;Whether the popup's one shadow rung still separates the list under forced colours is untested, and no prefers-contrast rule exists.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="select" />

## Installation [#installation]

<ComponentInstall name="select" unbuilt="false" importPath="@/components/ui/select" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" />

## Usage [#usage]

```tsx
import { Select } from "@/components/ui/select"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Choosing one value from a list too long to lay flat, such as a timezone.&#x22;,
  &#x22;A single choice on a dense form, where a closed trigger keeps a long list from pushing fields down.&#x22;,
  &#x22;A setting the reader changes rarely, so opening the list costs little.&#x22;,
]"
  avoid="[
  { case: &#x22;Fewer than about seven options, easier scanned flat.&#x22;, instead: &#x22;radio-group&#x22; },
  { case: &#x22;A clinical value with a range and a verdict, needing a control that formats it.&#x22;, instead: &#x22;reading-input&#x22; },
  { case: &#x22;The options open different views, so choosing one swaps what the reader sees.&#x22;, instead: &#x22;tabs&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="select"
  parts="[
  {
    name: &#x22;Select&#x22;,
    describes: &#x22;The root, rendered as the trigger button, the one tab stop.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Select.Value&#x22;,
    describes: &#x22;The chosen label, or the muted placeholder via data-placeholder.&#x22;,
    prop: &#x22;placeholder&#x22;,
  },
  {
    name: &#x22;Select.Icon&#x22;,
    describes: &#x22;The open indicator, a lucide ChevronsUpDown, aria-hidden.&#x22;,
  },
  {
    name: &#x22;Select.Popup&#x22;,
    describes: &#x22;The floating list, portalled past clipping, a raised card at trigger width.&#x22;,
  },
  {
    name: &#x22;Select.Item&#x22;,
    describes: &#x22;One option row, role=option with aria-selected, neutral state-hover.&#x22;,
    prop: &#x22;options&#x22;,
  },
  {
    name: &#x22;Select.ItemIndicator&#x22;,
    describes: &#x22;The tick on the chosen option, a lucide Check, selection without colour.&#x22;,
  },
]"
/>

<CompositionTree
  name="select"
  tree="[
  {
    part: &#x22;Select&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;Trigger, role combobox, one tab stop&#x22;,
    children: [
      { part: &#x22;Select.Value&#x22;, cardinality: &#x22;1&#x22;, note: &#x22;data-placeholder when unchosen&#x22; },
      { part: &#x22;Select.Icon&#x22;, cardinality: &#x22;1&#x22;, note: &#x22;ChevronsUpDown, aria-hidden&#x22; },
      {
        part: &#x22;Select.Popup&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;Portalled, role listbox, raised&#x22;,
        children: [
          {
            part: &#x22;Select.Item&#x22;,
            cardinality: &#x22;1..n&#x22;,
            note: &#x22;role option, aria-selected, per option&#x22;,
            children: [
              { part: &#x22;Select.ItemIndicator&#x22;, cardinality: &#x22;0..1&#x22;, note: &#x22;lucide Check, selected row&#x22; },
            ],
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Choosing from a list [#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.

<ComponentPreview name="select-choosing-from-a-list" kind="example" align="start" />

### With a placeholder [#with-a-placeholder]

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

<ComponentPreview name="select-with-a-placeholder" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **"Choose a timezone"** as the placeholder, with parallel labels: Lisbon, Berlin, Tokyo.
  </DoDont.Do>

  <DoDont.Dont>
    **"Lisbon"** as a placeholder while nothing is chosen. A default and a blank read alike.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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.

<KeyboardTable
  name="select"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus onto the trigger&#x22;,
    notes: &#x22;One tab stop; disabled selects are skipped.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back off the trigger&#x22;,
    notes: &#x22;Never enters the list.&#x22;,
  },
  {
    keys: &#x22;Enter, Space, Arrow Down, Arrow Up&#x22;,
    action: &#x22;Opens the list from the trigger&#x22;,
    notes: &#x22;Highlight lands on the chosen or first enabled option.&#x22;,
  },
  {
    keys: &#x22;Arrow Down, Arrow Up&#x22;,
    action: &#x22;Moves the highlight through the open list&#x22;,
    notes: &#x22;Disabled options are skipped. No wrapping.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Moves the highlight to the first or last option&#x22;,
    notes: &#x22;Both skip disabled options.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Chooses the highlighted option and closes the list&#x22;,
    notes: &#x22;Focus returns to the trigger, showing the value.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes the list without choosing&#x22;,
    notes: &#x22;The value is left as it was.&#x22;,
  },
  {
    keys: &#x22;Type a letter&#x22;,
    action: &#x22;Jumps to the next option whose label starts with what was typed&#x22;,
    notes: &#x22;Base UI type-to-find, unchecked against a screen reader.&#x22;,
  },
]"
/>

<ContrastReport component="select" />

## API reference [#api-reference]

<PropsTable name="SelectProps" />

`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 [#related]

* [RadioGroup](./radio-group.mdx): every option at once, better under about seven options.
* [SegmentedControl](./segmented-control.mdx): sets a parameter of one view from a small flat set.
* [ReadingInput](./reading-input.mdx): for a clinical value with a range and a meaning.
