Select
Lets people choose one or several options from a list that stays out of the way until it's needed.
import { Field, FieldLabel } from "@/components/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const regions = [
{ label: "Select a region", value: null },
{ label: "Frankfurt (eu-central-1)", value: "fra1" },
{ label: "London (eu-west-2)", value: "lhr1" },
{ label: "Washington, D.C. (us-east-1)", value: "iad1" },
{ label: "San Francisco (us-west-1)", value: "sfo1" },
{ label: "Tokyo (ap-northeast-1)", value: "hnd1" },
]
export default function SelectDemo() {
return (
<Field className="w-72">
<FieldLabel htmlFor="region">Deployment region</FieldLabel>
<Select items={regions} defaultValue="fra1">
<SelectTrigger id="region" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{regions.slice(1).map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)
}Anatomy
- 1TriggerLooks like an input and shares its heights, so a select lines up with text fields in a form.
- 2ValueThe selected option's label — or a placeholder in muted text when nothing is chosen.
- 3IconUp and down chevrons: the list opens over the trigger with the selected option in place.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/select.jsonUsage
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"const regions = [
{ label: "Select a region", value: null },
{ label: "Frankfurt", value: "fra1" },
{ label: "Tokyo", value: "hnd1" },
]
<Select items={regions}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="fra1">Frankfurt</SelectItem>
<SelectItem value="hnd1">Tokyo</SelectItem>
</SelectGroup>
</SelectContent>
</Select>Pass items to the root. It lets SelectValue render the label of the selected option instead of its raw value, and an item with value: null becomes the placeholder shown while nothing is selected. Render that placeholder item in the list only when clearing the selection is a real choice — and then label it as one, for example No role.
Examples
Groups
Group long lists under labels and divide the groups with separators. Keep labels short nouns; they are headings, not options, and can't be selected.
import * as React from "react"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const timezones = [
{
region: "Americas",
zones: [
{ label: "New York (GMT−5)", value: "america/new_york" },
{ label: "Chicago (GMT−6)", value: "america/chicago" },
{ label: "Los Angeles (GMT−8)", value: "america/los_angeles" },
{ label: "São Paulo (GMT−3)", value: "america/sao_paulo" },
],
},
{
region: "Europe",
zones: [
{ label: "London (GMT+0)", value: "europe/london" },
{ label: "Berlin (GMT+1)", value: "europe/berlin" },
{ label: "Warsaw (GMT+1)", value: "europe/warsaw" },
],
},
{
region: "Asia–Pacific",
zones: [
{ label: "Singapore (GMT+8)", value: "asia/singapore" },
{ label: "Tokyo (GMT+9)", value: "asia/tokyo" },
{ label: "Sydney (GMT+11)", value: "australia/sydney" },
],
},
]
const items = [
{ label: "Select a time zone", value: null },
...timezones.flatMap((group) => group.zones),
]
export default function SelectGroups() {
return (
<Select items={items}>
<SelectTrigger className="w-64" aria-label="Time zone">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timezones.map((group, index) => (
<React.Fragment key={group.region}>
{index > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>{group.region}</SelectLabel>
{group.zones.map((zone) => (
<SelectItem key={zone.value} value={zone.value}>
{zone.label}
</SelectItem>
))}
</SelectGroup>
</React.Fragment>
))}
</SelectContent>
</Select>
)
}Sizes
The trigger comes in sm, default and lg — the same 32, 36 and 40px heights as Input and Button, so a filter bar lines up without a single custom class.
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const periods = [
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 90 days", value: "90d" },
]
const sizes = ["sm", "default", "lg"] as const
export default function SelectSizes() {
return (
<div className="flex flex-col items-center gap-4">
{sizes.map((size) => (
<div key={size} className="flex items-center gap-2">
<Select items={periods} defaultValue="30d">
<SelectTrigger size={size} className="w-40" aria-label="Period">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{periods.map((period) => (
<SelectItem key={period.value} value={period.value}>
{period.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input size={size} placeholder="Filter events" className="w-40" />
<Button size={size} variant="outline">
Export
</Button>
</div>
))}
</div>
)
}Item-aligned and popper positioning
By default the list opens aligned with the trigger: the selected option sits exactly over the trigger, like a native macOS menu, so the eye doesn't have to travel. Set alignItemWithTrigger={false} on SelectContent for a classic dropdown that opens below — better near the bottom of a viewport, inside dense toolbars, or whenever covering the trigger would hide context.
The selected item opens over the trigger, like a native menu.
The list drops below the trigger and never covers it.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const fonts = [
{ label: "Inter", value: "inter" },
{ label: "Geist Mono", value: "geist-mono" },
{ label: "System UI", value: "system" },
{ label: "Georgia", value: "georgia" },
]
function FontSelect({ popper }: { popper?: boolean }) {
return (
<Select items={fonts} defaultValue="geist-mono">
<SelectTrigger
id={popper ? "font-popper" : "font-aligned"}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={!popper}>
<SelectGroup>
{fonts.map((font) => (
<SelectItem key={font.value} value={font.value}>
{font.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export default function SelectPositioning() {
return (
<div className="grid w-full max-w-lg gap-8 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="font-aligned">Item-aligned</FieldLabel>
<FontSelect />
<FieldDescription>
The selected item opens over the trigger, like a native menu.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="font-popper">Popper</FieldLabel>
<FontSelect popper />
<FieldDescription>
The list drops below the trigger and never covers it.
</FieldDescription>
</Field>
</div>
)
}Multiple selection
Set multiple and give defaultValue an array. Pass a function to SelectValue to decide how many selections to spell out before summarizing — long comma-separated lists get truncated and stop being readable.
"use client"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const channels = [
{ label: "Email", value: "email" },
{ label: "Push notifications", value: "push" },
{ label: "SMS", value: "sms" },
{ label: "Slack", value: "slack" },
{ label: "Microsoft Teams", value: "teams" },
]
export default function SelectMultiple() {
return (
<Select items={channels} multiple defaultValue={["email", "slack"]}>
<SelectTrigger className="w-64" aria-label="Notification channels">
<SelectValue>
{(value: string[]) => {
if (value.length === 0) return "Choose channels"
if (value.length === 1) {
return channels.find((channel) => channel.value === value[0])
?.label
}
return `${value.length} channels`
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{channels.map((channel) => (
<SelectItem key={channel.value} value={channel.value}>
{channel.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}<Select items={channels} multiple defaultValue={["email", "slack"]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) =>
value.length === 1 ? labelFor(value[0]) : `${value.length} channels`
}
</SelectValue>
</SelectTrigger>
…
</Select>Rich options and object values
Options can carry more than a label. When values are objects, tell the select how to serialize them for forms with itemToStringValue, and render the selection yourself through SelectValue's render function. Let the trigger grow with h-auto: the size is a plain height class, so a height in className replaces it.
"use client"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const plans = [
{
name: "Hobby",
price: "$0",
description: "For personal projects and experiments.",
},
{
name: "Pro",
price: "$20",
description: "For professionals shipping to production.",
},
{
name: "Team",
price: "$60",
description: "Shared workspaces, roles and audit logs.",
},
]
type Plan = (typeof plans)[number]
function PlanOption({ plan }: { plan: Plan }) {
return (
<span className="flex w-full flex-col items-start gap-0.5 text-left">
<span className="flex w-full items-baseline justify-between gap-4 text-sm font-medium text-foreground">
{plan.name}
<span className="font-normal text-muted-foreground tabular-nums">
{plan.price}/mo
</span>
</span>
<span className="text-xs whitespace-normal text-muted-foreground">
{plan.description}
</span>
</span>
)
}
export default function SelectRichItems() {
return (
<Select
defaultValue={plans[1]}
itemToStringValue={(plan: Plan) => plan.name}
>
<SelectTrigger
className="h-auto w-full max-w-80 py-2.5"
aria-label="Plan"
>
<SelectValue>{(plan: Plan) => <PlanOption plan={plan} />}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{plans.map((plan) => (
<SelectItem key={plan.name} value={plan} className="py-2">
<PlanOption plan={plan} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}With icons and disabled options
Leading visuals — status dots, flags, avatars — help people scan. Put them in both the option and the value render function so the trigger reflects the choice. Disabled options stay visible so people learn they exist, but can't be highlighted or chosen.
"use client"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
const statuses = [
{
label: "Backlog",
value: "backlog",
dot: "border border-gray-9 bg-transparent",
},
{ label: "Todo", value: "todo", dot: "bg-gray-9" },
{ label: "In progress", value: "in-progress", dot: "bg-warning-9" },
{ label: "In review", value: "in-review", dot: "bg-brand-9" },
{ label: "Done", value: "done", dot: "bg-success-9" },
{ label: "Canceled", value: "canceled", dot: "bg-danger-9", disabled: true },
]
function StatusDot({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn("size-2 shrink-0 rounded-full", className)}
/>
)
}
export default function SelectStatus() {
return (
<Select items={statuses} defaultValue="in-progress">
<SelectTrigger className="w-48" aria-label="Status">
<SelectValue>
{(value: string) => {
const status = statuses.find((item) => item.value === value)
return (
<>
<StatusDot className={status?.dot} />
{status?.label}
</>
)
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{statuses.map((status) => (
<SelectItem
key={status.value}
value={status.value}
disabled={status.disabled}
>
<StatusDot className={status.dot} />
{status.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}In a form
Compose with Field for the label, description and error. FieldLabel's htmlFor points at the trigger's id; name and required submit and validate the value like a native control. Mark the trigger aria-invalid and the field data-invalid to show an error.
Used for emails and the dashboard.
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const roles = [
{ label: "Select a role", value: null },
{ label: "Viewer", value: "viewer" },
{ label: "Editor", value: "editor" },
{ label: "Admin", value: "admin" },
{ label: "Owner", value: "owner" },
]
const languages = [
{ label: "English", value: "en" },
{ label: "Polski", value: "pl" },
{ label: "Deutsch", value: "de" },
]
export default function SelectForm() {
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="language">Language</FieldLabel>
<Select items={languages} name="language" defaultValue="en">
<SelectTrigger id="language" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{languages.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>Used for emails and the dashboard.</FieldDescription>
</Field>
<Field data-invalid>
<FieldLabel htmlFor="role">Role</FieldLabel>
<Select items={roles} name="role" required>
<SelectTrigger id="role" className="w-full" aria-invalid>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{roles.slice(1).map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError>Choose a role before sending the invite.</FieldError>
</Field>
<Field data-disabled>
<FieldLabel htmlFor="workspace">Workspace</FieldLabel>
<Select
items={[{ label: "Acme Inc.", value: "acme" }]}
defaultValue="acme"
disabled
>
<SelectTrigger id="workspace" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="acme">Acme Inc.</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
)
}Guidelines
When to use
- To choose from five to fifteen mutually exclusive options that don't need to be compared side by side.
- When space is tight and the current choice is more important than the alternatives.
When not to use
- For two to five options that fit on screen — show them all with a Radio Group or Toggle Group; hiding them behind a click slows people down.
- For long lists people need to search — use a Combobox.
- To trigger actions such as Duplicate or Delete — use a Dropdown Menu. A select holds a value; a menu runs a command.
- When the platform's native picker is preferable, for example on mobile-heavy forms — use Native Select.
Order and wording
Order options the way people think about them: by frequency, by magnitude (Small, Medium, Large) or alphabetically — never by the order they were added to the database. Keep labels short and parallel, and make the placeholder describe the action (Select a region), not the field (Region).
Defaults
Preselect a value when there's a safe, common answer — it saves a decision for most people. Leave the select empty when choosing wrong has consequences (a billing country, a permission level), so the choice is deliberate and validation can catch an omission.
Accessibility
The trigger is a button with role="combobox" that controls a listbox popup. Base UI manages focus, aria-expanded, keyboard highlighting and typeahead, and submits the value through a hidden input.
| Key | Behavior |
|---|---|
EnterSpace↓↑ | Opens the list with the selected option highlighted. With the list open, Enter or Space chooses the highlighted option. |
↓↑ | Moves the highlight between options, skipping disabled ones. |
HomeEnd | Highlights the first or last option. |
A–Z | Typeahead: jumps to the next option starting with the typed characters. |
Esc | Closes the list without changing the value and returns focus to the trigger. |
Tab | Closes the list and moves focus to the next control. |
- Label every select. Point a visible
FieldLabelat the trigger withhtmlFor, or give the trigger anaria-labelwhen the context makes the purpose obvious visually (for example a toolbar). - Boundaries. The trigger's border uses the
inputtoken, which meets 3:1 against the page for WCAG 1.4.11. Selected and highlighted states never rely on color alone: the selected option carries a check mark. - Target size. Options are at least 32px tall and the trigger at least 32px, above the 24×24px minimum of WCAG 2.2.
- Modality. The open list is modal by default: page scroll locks and outside clicks close it first. Set
modal={false}onSelectif the page must stay interactive.
API reference
Select
The root. Doesn't render an element. Accepts every prop of Base UI's Select.Root.
SelectTrigger
The button that shows the value and opens the list. Renders a <button>.
SelectValue
Displays the selection inside the trigger. Renders a <span>.
SelectContent
The positioned popup and its scrollable list. It renders SelectScrollUpButton and SelectScrollDownButton at the edges of the list for you; both are exported for building a custom popup.
SelectItem
An option. Renders a <div> with role="option".
SelectGroup, SelectLabel, SelectSeparator
SelectGroup groups options, SelectLabel titles a group and must sit inside one, and SelectSeparator draws a divider between groups. They accept the props of their Base UI counterparts.