Select
Picks one value from a list that doesn't need to stay on screen — a service, a staff member, a duration. A squircle field the height of a Button, over the vendored Base UI select. On a phone the list opens as a bottom sheet, as every dialog does there: tall rows, the page dimmed, a handle to drag it down by.
Playground
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shared/select";
const services = [
{ value: "svc_fade", label: "Skin fade" },
{ value: "svc_classic", label: "Classic cut" },
{ value: "svc_buzz", label: "Buzz cut" },
{ value: "svc_beard", label: "Beard trim" },
{ value: "svc_shave", label: "Hot towel shave" },
];
<Select items={services}>
<SelectTrigger aria-label="Service" className="w-48">
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>
{services.map((service) => (
<SelectItem key={service.value} value={service.value}>
{service.label}
</SelectItem>
))}
</SelectContent>
</Select>Sizes
sm 32px, md 36px by default, lg 40px — the same heights as Button and Input, so a select and the action beside it sit on one line.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shared/select";
<Select items={durations} defaultValue="30 min">
<SelectTrigger size="sm" aria-label="Slot length">
<SelectValue />
</SelectTrigger>
<SelectContent>…</SelectContent>
</Select>
<Button size="sm" variant="secondary">Apply</Button>Labels, not ids
The trigger shows the chosen value as it is. When values are ids, pass items — the same list you render — so it shows “Maja”, not stf_maja.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shared/select";
const staff = [
{ value: "any", label: "Anyone available" },
{ value: "stf_maja", label: "Maja" },
{ value: "stf_tomas", label: "Tomáš" },
{ value: "stf_lucia", label: "Lucia" },
];
<Select items={staff} defaultValue="stf_maja">
<SelectTrigger aria-label="Staff member" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{staff.map((member) => (
<SelectItem key={member.value} value={member.value}>
{member.label}
</SelectItem>
))}
</SelectContent>
</Select>Groups
Past half a dozen options, group them under a SelectLabel and part the groups with a SelectSeparator.
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/shared/select";
<Select items={services}>
<SelectTrigger aria-label="Service" className="w-48">
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Cuts</SelectLabel>
<SelectItem value="svc_fade">Skin fade</SelectItem>
<SelectItem value="svc_classic">Classic cut</SelectItem>
<SelectItem value="svc_buzz">Buzz cut</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Beard</SelectLabel>
<SelectItem value="svc_beard">Beard trim</SelectItem>
<SelectItem value="svc_shave">Hot towel shave</SelectItem>
</SelectGroup>
</SelectContent>
</Select>In a form
Give the Select an id — it lands on the trigger — and point the FieldLabel's htmlFor at it. Stretch the trigger with className="w-full".
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shared/select";
import { Field, FieldLabel } from "@/components/shared/field";
<Field>
<FieldLabel htmlFor="booking-service">Service</FieldLabel>
<Select id="booking-service" items={services}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>…</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="booking-staff">Staff member</FieldLabel>
<Select id="booking-staff" items={staff} defaultValue="any">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>…</SelectContent>
</Select>
</Field>Disabled & invalid
Disable the whole Select, or single items. Mark the trigger aria-invalid for a destructive edge, and say what to do in the text below it.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shared/select";
import { Field, FieldError, FieldLabel } from "@/components/shared/field";
<Select items={staff} defaultValue="stf_lucia" disabled>…</Select>
<SelectItem value="stf_tomas" disabled>Tomáš</SelectItem>
<Field>
<FieldLabel htmlFor="booking-service">Service</FieldLabel>
<Select id="booking-service" items={services}>
<SelectTrigger aria-invalid aria-describedby="service-error">
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>…</SelectContent>
</Select>
<FieldError id="service-error">Pick a service to see free times.</FieldError>
</Field>API reference
Select
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Value | null | — | Controlled value; null shows the placeholder. Pair with onValueChange. |
| defaultValue | Value | null | — | Initial value when uncontrolled. |
| onValueChange | (value: Value | null, details) => void | — | Called with the chosen item's value. |
| items | { value, label }[] | Record<string, ReactNode> | — | The options' labels, so the trigger shows a label rather than the raw value. Pass it whenever values are ids. |
| id | string | — | Lands on the trigger, for a FieldLabel's htmlFor. |
| disabled | boolean | false | Dims the trigger and ignores presses. |
| name | string | — | Submits the value with a form, via a hidden input. |
SelectTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "md" | 32, 36 or 40px tall — Button's heights; 40 at sm and md on a touch screen. |
| aria-invalid | boolean | — | A destructive edge; describe the fix nearby. |
| aria-label | string | — | The name when no FieldLabel points at it. |
| className | string | — | Layout only — w-full, a fixed width. Never restyle the field here. |
SelectValue, SelectContent, SelectItem
| Prop | Type | Default | Description |
|---|---|---|---|
| SelectValue placeholder | ReactNode | — | Muted text in the trigger while nothing is chosen. |
| SelectContent alignItemWithTrigger | boolean | true | Opens the list over the trigger with the chosen item aligned to it; false drops it below. Always off for touch. |
| SelectContent side / align | "top" | "bottom" … / "start" | "center" | "end" | "bottom" / "center" | Placement when not aligned to the trigger. |
| SelectItem value | Value | — | What the Select's value becomes. Children are the label in the list. |
| SelectItem disabled | boolean | false | Shown, dimmed, and skipped by the keyboard. |
SelectGroup wraps related items under a SelectLabel; SelectSeparator draws a hairline between groups. Everything else passes through to the Base UI select parts.
Guidelines
- Reach for a Select at roughly five to fifteen options. Two to four that deserve to be seen side by side belong in a RadioGroup; a long, searchable list (clients) needs a combobox instead.
- Pass
itemswhenever values are ids — without it the trigger shows the raw value. - Every select has a name: a FieldLabel pointing at its
id, or anaria-labelon the trigger. The placeholder is not a name. - A placeholder prompts (“Pick a service”); it never stands in for a real choice. When “anyone” is a valid answer, make it an item — “Anyone available” — and preselect it.
- Options are short, in sentence case, the distinguishing word first — “Skin fade · 45 min”, not “45 min · Skin fade” — so typing a letter jumps to the right one.
- Match sizes with neighbours: a select beside a
smbutton issmtoo.