Installation
npx shadcn@latest add @pixeldosa/selectThe example above also uses Field. To copy it as-is, install everything it needs:
npx shadcn@latest add @pixeldosa/select @pixeldosa/fieldRequires `cn` at `@/lib/utils`. Option values must be non-empty strings. A `prefix` is hidden from screen readers, so name the control with `aria-label` or a label.
Usage
import { Select } from "@/components/ui/select";
export function ModelPicker({ model, onModelChange }: Props) {
return (
<Select
aria-label="Model"
value={model}
onValueChange={onModelChange}
options={[
{ value: "fast", label: "Fast: drafts in seconds" },
{ value: "balanced", label: "Balanced" },
{ value: "careful", label: "Careful: checks its sources" },
]}
/>
);
}
options takes plain strings when what's shown is what's stored, or { value, label }
when they differ. Pass value with onValueChange when you own the state, or
defaultValue when you don't.
Features
- The list is drawn by the system. It opens in the same popover surface as every other overlay, in both themes, instead of an OS menu in the OS's colours and fonts.
- Native behaviour kept. Space, Enter and the arrow keys open it, arrows move, typing jumps to a match, and Escape or Tab close it with focus back on the trigger.
- Sized to the choice. The trigger is as wide as its current value, not its longest option, so a row of pills stays compact.
- Works in forms. Inside
FieldControlit receivesid,aria-invalidandaria-describedbylike any input. Withname, a hidden native select submits the value with the form. - Touch-sized options. Rows are 32px for a mouse and 44px on touch screens.
Variants
Field
The default. It sits in a form beside inputs, at Field height, or 32px with size="sm".
Pill
A compact chip for toolbars and composers. prefix shows a label before the value; it's
visual only, so name the control with aria-label.
Placeholder and disabled options
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | (string | { value, label?, disabled? })[] | — | Required. Values must be non-empty strings. |
value / onValueChange | string / (value: string) => void | — | The selected value when you own the state, and the callback with each pick. |
defaultValue | string | — | The starting value when the component owns the state. |
placeholder | string | — | Shown, muted, while nothing is selected. |
variant | "field" | "pill" | "field" | A form control, or a compact chip for toolbars. |
size | "sm" | "default" | "default" | Height of the field variant: Field height, or 32px. |
prefix | string | — | Visual label before the value, such as Tone:. Hidden from screen readers. |
name / required | string / boolean | — | Submit the value with a form, and require a choice first. |
disabled | boolean | false | The whole control can't be opened. |
Other props, such as id, aria-label and className, go to the trigger button.
Accessibility
- The trigger is a combobox over a listbox, so a screen reader announces its name, its role and the current value, and each option's selected state.
- Name it with
aria-label, a<label htmlFor>pointing at itsid, orFieldLabelinside aField. Aprefixis hidden from screen readers so it isn't read twice. - Focus returns to the trigger when the list closes, by a pick, Escape or Tab.
- The pill's hit area reaches 44px tall without making the chip bigger.
When not to use
Searching a long or remote list is Command Menu. Two or three choices that should
stay visible side by side are better as buttons. A yes or no setting is a toggle, not a
select.
why it's built this way
Engineering Notes
Replaces the native selects the system used to use. A native select was the right default for input methods and phones, but its open list is an OS menu in the OS's colours and fonts, and it was the one surface the system couldn't style, which showed most in the dark theme. Radix Select keeps what native gave and draws the list itself: Space, Enter and arrows open it, arrows move, typeahead jumps, Escape and Tab close it with focus back on the trigger, and the trigger is a combobox over a listbox, so screen readers announce the label, the role and the current value. The API is data rather than composition (`options`, `value`, `onValueChange`), so a model rendering from the catalogue can drive it with plain JSON, and so both of the system's call sites, Intent Preview's corrections and Prompt Composer's pills, are one element each. The trigger sizes to its current value rather than the longest option, which is what Prompt Composer's pills had to fake with a transparent native select laid over a label. The pill's hit area stretches to 44px vertically because pills sit in a row; the field variant doesn't, because stacked fields would overlap. Options are 32px rows for a mouse and 44px on coarse pointers. With `name` set, Radix renders a hidden native select, so plain form submission keeps working.
how it moves, and when it doesn't
Motion Notes
Opening is the only motion, and it's brief: the list fades in and settles from 98% scale over `--pd-duration-fast` with `--pd-ease-decelerate`, growing out of the trigger because its transform origin comes from the side Radix opened it on. It opens because the person asked, so it holds still from then on: no staggered rows, no sliding highlight, and the highlight moves instantly with the keys. Closing is instant. The trigger shares the system's colour transition at `--pd-duration-instant`. Under prefers-reduced-motion the list appears with no fade or scale, and the trigger's transition is dropped.