Forms
Select
A labeled list of choices on the Field contract: pass label, options (or SelectItem children) and error from your server action or client check. With multiple the value is a string[]. On a phone the list opens as a bottom sheet you scroll with your thumb; elsewhere it opens next to the trigger, over the chosen option. It submits its value under name in a plain form post. Covers Catalyst's Listbox.
In Spoonjoy
Spoonjoy, a recipe app, is built with Ouro HX (how).
In its source: app/routes/recipes.$id.steps.$stepId.edit.tsx · app/routes/recipes.$id.steps.new.tsx
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/select#v1.2.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/selectIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/select#v1.2.13 ourostack/ouro-ui/select-tests#v1.2.13import { Select } from "@/components/ouro/select/select";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<Select
label="Course"
name="course"
placeholder="Choose a course"
options={[{ value: "starter", label: "Starter" }, { value: "main", label: "Main" }]}
error={errors?.course}
/>
<Select label="Tags" name="tags" multiple defaultValue={["quick"]} options={tags} />Usage
A multiple Select posts one entry per chosen value, all under name, in the order they were chosen, and no entry when nothing is chosen: read them with formData.getAll("tags"), not formData.get. In a test, open the list and choose an option with real pointer events (userEvent.click in Vitest browser mode, locator.click() in Playwright): the options act on pointer events, so a synthetic fireEvent.click chooses nothing.
Use instead when
- Use
NativeSelectinstead for the operating system's own picker (long lists of plain text on phones, such as countries),RadioGroupfor two to five choices that should all show, andComboboxwhen the user needs to type to filter.
Props
The props the modes share. Type: SelectProps.
autoComplete- Type:
string - Autofill hint for the hidden input that submits the value, such as
"country". children- Type:
ReactNode SelectItems andSelectGroups, when you do not passoptions.className- Type:
string - Layout only: margins, width, grid placement (
mt-4,col-span-2). Appearance classes warn in development; style fields in your theme. description- Type:
ReactNode - Supporting text under the label, read after the label through
aria-describedby. disabled- Type:
boolean - Disables the select and fades it.
error- Type:
ReadonlyErrorMessages - What is wrong, from your server action or client check. The message (or several, as a list) shows under the select, which gets
aria-invalid. A message is text or a React node (one with a link); an empty string or array means valid. form- Type:
string - The id of a form outside which the select sits, so its value submits with that form.
hideLabel- Type:
boolean - Hides the label visually; screen readers still read it as the select's name.
label(required)- Type:
string - What the select is for, shown above it and read as its name. Required: every control needs a name. To hide it visually, keep it and add
hideLabel. On phones it also heads the list. name- Type:
string - The form field name the value submits under. With
multiple, each value submits under it. options- Type:
SelectOptions - The choices:
[{ value: "main", label: "Main" }], and groups as{ label: "Fruit", options: [...] }. PassSelectItemchildren instead when an option needs more than text. placeholder- Type:
string - Shown in the trigger while nothing is chosen:
"Choose a course". It is not a label. readOnly- Type:
boolean - The value can be seen but not changed.
ref- Type:
Ref<HTMLButtonElement> - The trigger button.
required- Type:
boolean - A choice is required: the form will not submit without one, and the label gets a marker.
Modes
SelectProps is a union: pass the props of exactly one mode. Mixing them is a type error.
Multiple: SelectMultipleProps
A Select that picks several values, as a string[].
defaultValue- Type:
string[] - The value chosen at first, when the select keeps its own state: a
stringornull; withmultiple, astring[]. multiple(required)- Type:
true - Pick several values: the list stays open while the user chooses, and the value is a
string[]instead ofstring | null. onValueChange- Type:
((value: string[]) => void) - Called with the new value when the user chooses an option: a
stringornull; withmultiple, every chosen value as astring[]. value- Type:
string[] - The chosen value, when you control it: a
string, ornullfor none; withmultiple, astring[].
Single: SelectSingleProps
A Select that picks one value: a string, or null while nothing is chosen.
defaultValue- Type:
string | null - The value chosen at first, when the select keeps its own state: a
stringornull; withmultiple, astring[]. multiple- Type:
false - Pick several values: the list stays open while the user chooses, and the value is a
string[]instead ofstring | null. onValueChange- Type:
((value: string | null) => void) - Called with the new value when the user chooses an option: a
stringornull; withmultiple, every chosen value as astring[]. value- Type:
string | null - The chosen value, when you control it: a
string, ornullfor none; withmultiple, astring[].
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
errorMessage: Not a prop. Ouro UI useserror:<Select error="Required.">.helperText: Not a prop. Ouro UI usesdescription:<Select description="...">.invalid: Not a prop.errormarks the select invalid:<Select error="What is wrong">.isDisabled: Not a prop. Ouro UI usesdisabled:<Select disabled>.isInvalid: Not a prop.errormarks the select invalid:<Select error="What is wrong">.isMulti: Not a prop. Ouro UI usesmultiple:<Select multiple>.isRequired: Not a prop. Ouro UI usesrequired:<Select required>.items: Not a prop. Ouro UI usesoptions:<Select options={[{ value: "main", label: "Main" }]}>.onChange: Not a prop. Select callsonValueChangewith the value:<Select onValueChange={(value) => setCourse(value)}>.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: Select merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Parts
For what these props cannot express, pass as children the parts in @/components/ouro/select/select-parts. The nesting they need follows.
import {
SelectGroup,
SelectItem,
} from "@/components/ouro/select/select-parts";Required nesting
<Select label="Produce" name="produce">
<SelectGroup label="Fruit">
<SelectItem value="apple">Apple</SelectItem>
</SelectGroup>
</Select>Each part checks that it sits inside <Select>: outside it, a part throws in development with a message that shows this JSX shape.select-nesting
SelectGroup- What it is: A labeled group of
SelectItems. Withoptions, pass a group as{ label: "Fruit", options: [...] }instead. SelectItem- What it is: One option in a
Select, for options that need more than text (an icon, a color swatch). PassoptionstoSelectinstead when every option is text; it renders these for you.
Types
ErrorMessage
One error message: text, or a React element for a message that carries a link or other markup.
type ErrorMessage = string | ReactElement;ReadonlyErrorMessages
A read-only message view accepted by fields and Form; mutable input arrays remain valid.
type ReadonlyErrorMessages = ErrorMessage | readonly ErrorMessage[];Claim sources
- layout-classes: test:registry/ouro/components/ouro/select/select.test.tsx::Select > forwards a ref to the trigger and lets layout classes win
verify:testExists(registry/ouro/components/ouro/select/select.test.tsx::Select > forwards a ref to the trigger and lets layout classes win)