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):
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled selected value. Pair with onValueChange. |
defaultValue | string | — | Initial value when the select is uncontrolled. |
onValueChange | (value: string) => void | — | Fires when the user picks a different item. |
open / defaultOpen | boolean | — | Controlled / uncontrolled open state of the listbox. |
onOpenChange | (open: boolean) => void | — | Fires when the listbox opens or closes. |
disabled | boolean | false | Disables the whole control. |
required | boolean | false | Marks the control required when it takes part in a native form. |
name | string | — | Name of the hidden native input rendered for form submission. |
SelectTrigger props:
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'default' | 'default' | Trigger height — sm is 32px, default is 36px. Rendered as data-size on the element. |
aria-invalid | boolean | — | Switches the border to --destructive-ink and adds the destructive ring. |
className | string | — | Additional Tailwind classes merged onto the trigger. |
...rest | Radix SelectTrigger props | — | Everything else is forwarded to the Radix trigger. The chevron icon is rendered by the component. |
The remaining parts:
| Prop | Type | Default | Description |
|---|---|---|---|
SelectValue placeholder | ReactNode | — | Shown 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 valuerequired | string | — | Value written back by onValueChange. Radix rejects an empty string — use SELECT_EMPTY_VALUE for a "none" row. |
SelectItem disabled | boolean | false | Dims the row and skips it during keyboard navigation. |
SelectGroup / SelectLabel | div / label parts | — | Wrap related items in a group and title it with a label; the label is announced as the group name. |
SelectSeparator | div part | — | A 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 viaid/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-inputat rest,border-control-edge-strongon hover,border-primary-inkon focus, andborder-destructive-inkwhenaria-invalidis set. Underdata-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.