Skip to content
Folio

Forms

Select

A choice from a list, styled the whole way down.

Usage

When to reach for it

Up to roughly a dozen options. Past that a Combobox wins, because a list nobody can filter is slower to scan than one you can type into.
TSX
import { Select } from '@misoto22/design'

Notes

A choice from a list, styled the whole way down.

The option list is ours — drawn from the same tokens as everything else, so it does not change typeface, spacing and selection colour the moment it opens. That is the whole reason this replaced the native control as the default: a design system whose most common form control stops being part of the system on click is not a design system, it is a stylesheet for closed states.

The keyboard contract is Radix's, which means it is the platform's: typeahead works, the arrows move, Home and End reach the ends, and Escape closes without choosing. That was the one genuine argument for staying native, and it is answered.

Past roughly a dozen options, reach for Combobox — a list nobody can filter is worse than one they can type into. Where the platform picker is genuinely better — a phone, or a form that must survive without JavaScript — reach for NativeSelect.

Anatomy

Select anatomy
ElementDescription
TriggerrequiredA <button role="combobox"> on CONTROL_BASE, so it matches the Input beside it exactly. It is named by the label and by its own value together, and carries the chevron that turns over while the panel is open.
ValueThe chosen item’s text, or placeholder in --ink-3-aa when nothing is chosen. It truncates, and it is the half of the accessible name that says what was picked.
PanelPortalled, at least as wide as the trigger and at most 18rem tall, with a scroll chevron appearing at each end once the list is longer than that.
ItemOne option. The 3.5 tick box is always drawn and only the tick inside it appears, so choosing does not shunt every label sideways; data-highlighted is the fill, the tick is the chosen one.
Group headingSelectLabel — a mono eyebrow inside a SelectGroup. It is a heading, not an option, which is the difference between it and a disabled item used as a divider.

Best practices

Do

  • Pass name when the value has to survive a submit. The trigger is a <button>; Radix renders the hidden native <select> that carries the value only when the control is inside a <form>, and only a named one sends anything.
  • Pick one spelling of invalid. The trigger reads the invalid prop and aria-invalid alike, including the one a Field sets from error, so setting both is two sources of truth for one border.
  • Divide long lists with SelectGroup and SelectLabel rather than a disabled item used as a heading: a disabled item is still an option, so a screen reader counts it and announces the list as one longer than it is.

Don’t

  • Inside a Field with a label, the label prop here is not announced — the field’s words name the trigger — so a label that disagrees with the one above it is dead text nobody will ever hear.
  • Do not lean on the closed trigger to show a long option: it truncates to keep the field’s height, so the end of the value is only readable with the panel open.
  • Do not point contentClassName at the trigger. className is the trigger; contentClassName is the panel, and confusing them is how a select gets a 18rem-wide dropdown over the thing it is choosing for.

Examples

default

Two groups under mono headings, divided by a hairline. Reach for SelectGroup and SelectLabel rather than a disabled item used as a heading: a disabled item is still an option, so a screen reader counts it and announces a list one longer than it is. The panel is ours the whole way down, which is the point — it does not change typeface, spacing and selection colour the moment it opens. Past roughly a dozen options this becomes a Combobox, because a list nobody can filter is slower to scan than one you can type into.

The list is ours, not the platform’s — open it.

invalid

The one control on this base that does not read aria-invalid. Field's error paints Input, Textarea and NativeSelect on its own, because all three read either spelling — the Select trigger looks only at invalid, so a row given the error and nothing else ends up with a red message under a resting border. Pass both. Note the placeholder rather than a defaultValue: a select that opens already answered is one nobody has actually answered.

Choose a plan before inviting your team.

inside a form

name, and what it is for. The trigger is a button, and Radix renders the hidden native select carrying the value only when the control is inside a form — and only a named one sends anything, so a select without name submits nothing at all and the server sees a field nobody filled in. Save, and the line below prints what FormData actually received.

Parts

Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.

SelectItem

One option. The tick marks the chosen one; the fill marks the highlighted one.

Also accepts everything in ComponentProps<typeof SelectPrimitive.Item>. Those are forwarded to the underlying element and are not listed row by row.

SelectLabel

Mono eyebrow heading for a group of options.

Must sit inside a SelectGroup — Radix throws otherwise, because a heading with no group is a heading for nothing, and assistive tech would announce it as an option.

Takes no props of its own.

SelectSeparator

Hairline divider between groups.

Takes no props of its own.

Re-exports

SelectRoot = SelectPrimitive.Root

Radix Select root, group and label, as typed passthroughs.

SelectGroup = SelectPrimitive.Group

Keyboard

Select keyboard interactions
KeyDoes
EnterSpaceOpens the list.
Moves between options.
a–zTypeahead — jumps to the next option starting with that letter.
HomeEndJumps to the first or last option.
EscapeCloses without choosing.

Accessibility

  • Inside a bounded frame — a device preview, an embedded console — wrap the subtree in `<OverlayContainer container={el}>`. The panel then renders into that element and collides with its edges instead of the viewport’s, and inherits the `dir` and `data-density` set there.
  • The option list is ours, so it does not change typeface, spacing and selection colour the moment it opens — which is what a native select does.
  • The keyboard contract is the platform's: typeahead, arrows, Home and End, Escape to close without choosing.
  • label is required, and it is announced WITH the value: the trigger reads "Region, Australia", because a value is not a name and a name without the value is not the answer. Inside a Field the FIELD’s label supplies the name half — the trigger’s aria-labelledby points at that label and at the value — and the label prop here is neither rendered nor announced, so one that disagrees with the words above it is text nobody will hear.