Components

Select

An accessible dropdown built on Radix UI, styled to match the design system. Compose a trigger, value, and items; pair with a label and hint/error text for full form fields.

Basic usage

Compose SelectTrigger, SelectValue, and SelectItem children.

Trigger sizes

The trigger has two heights. size="default" is 36px and lines up with Input and Button; size="sm" is 32px for toolbars and table filter rows.

Groups & separators

Long lists read better in sections. Wrap items in a SelectGroup, title each one with SelectLabel, and divide them with SelectSeparator. Row spacing comes from the item itself (mt-1 first:mt-0), so it stays identical whether an item sits inside a group or directly in the list. A list long enough to overflow the viewport also grows arrows at both ends — SelectScrollUpButton and SelectScrollDownButton. Both are exported, but SelectContent already renders them for you; import them only if you are assembling your own content shell.

States

Pair the select with a label and hint/error text. Set aria-invalid on the trigger for the error style.

Choose your primary region.

Please select a country.

The “nothing selected” row

Radix reserves the empty string for clearing a selection, so <SelectItem value="" /> throws. The package ships one shared sentinel instead of leaving every app to invent its own: SELECT_EMPTY_VALUE plus toSelectValue / fromSelectValue to convert at the boundary. Your model keeps '' and the sentinel never reaches a server payload.

model value: ''

Props

Select root props (forwarded to Radix):

PropTypeDefaultDescription
valuestringControlled selected value. Pair with onValueChange.
defaultValuestringInitial value when the select is uncontrolled.
onValueChange(value: string) => voidFires when the user picks a different item.
open / defaultOpenbooleanControlled / uncontrolled open state of the listbox.
onOpenChange(open: boolean) => voidFires when the listbox opens or closes.
disabledbooleanfalseDisables the whole control.
requiredbooleanfalseMarks the control required when it takes part in a native form.
namestringName of the hidden native input rendered for form submission.

SelectTrigger props:

PropTypeDefaultDescription
size'sm' | 'default''default'Trigger height — sm is 32px, default is 36px. Rendered as data-size on the element.
aria-invalidbooleanSwitches the border to --destructive-ink and adds the destructive ring.
classNamestringAdditional Tailwind classes merged onto the trigger.
...restRadix SelectTrigger propsEverything else is forwarded to the Radix trigger. The chevron icon is rendered by the component.

The remaining parts:

PropTypeDefaultDescription
SelectValue placeholderReactNodeShown while nothing is selected; picks up the muted-foreground colour via data-placeholder on the trigger.
SelectContent position'popper' | 'item-aligned''popper'Popper mode anchors the list under the trigger and matches its width; scroll buttons are rendered automatically.
SelectItem valuerequiredstringValue written back by onValueChange. Radix rejects an empty string — use SELECT_EMPTY_VALUE for a "none" row.
SelectItem disabledbooleanfalseDims the row and skips it during keyboard navigation.
SelectGroup / SelectLabeldiv / label partsWrap related items in a group and title it with a label; the label is announced as the group name.
SelectSeparatordiv partA one-pixel divider between groups. Pointer events are disabled so it can never swallow a click.

Accessibility

  • Built on Radix UI Select — full keyboard navigation and typeahead.
  • Focus is managed and trapped within the open listbox.
  • Associate a <label> with the trigger via id / aria-labelledby.
  • The trigger carries no fill of its own beyond bg-background, so its border is the only thing outlining the control. Each state gets its own role token, and every one of them is darker than rest, never lighter: border-input at rest, border-control-edge-strong on hover, border-primary-ink on focus, and border-destructive-ink when aria-invalid is set. Under data-contrast="high" those four steps measure 3.83:1, 10.55:1, 6.51:1 and 7.15:1, all past the 3:1 threshold of WCAG 1.4.11. The default palette keeps the lighter steps in light mode — 1.16:1 at rest, 1.62:1 on hover, 1.86:1 on focus — so what it guarantees there is the ordering, not the ratio.
  • The selected item is marked by a check mark, not by its background. The highlight tint (--accent, shared with the hovered row) sits at roughly 1:1 against the popover and is a soft cue only; the check is a shape, satisfying WCAG 1.4.1.