Select
A button that opens a list of options and shows the one chosen.
'use client';
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field';
import { Select } from '@/components/panelui/select';
const PLANS = [
{ value: 'solo', label: 'Solo', price: 'Free, one editor' },
{ value: 'team', label: 'Team', price: '$12 per editor a month' },
{ value: 'studio', label: 'Studio', price: '$29 per editor a month' },
{ value: 'enterprise', label: 'Enterprise', price: 'Priced with you' },
];
export default function SelectPlans() {
return (
<Field className="w-full max-w-xs">
<FieldLabel>Plan</FieldLabel>
<Select items={PLANS} defaultValue="team">
<Select.Trigger>
<Select.Value placeholder="Pick a plan" />
</Select.Trigger>
<Select.Content>
{PLANS.map((plan) => (
<Select.Item key={plan.value} value={plan.value} description={plan.price}>
{plan.label}
</Select.Item>
))}
</Select.Content>
</Select>
<FieldDescription>You can change plan at the end of any month.</FieldDescription>
</Field>
);
}Use it to choose one option from a list that is known in advance: a plan, a time zone, a sort order. For a list long enough to search, use a combobox, and for two or three options that should all be seen at once, radio buttons.
Opened with a pointer, the list is placed so the chosen option sits over the trigger. Where there is no room for that, it opens below.
Pass the options as items to the root, and the trigger shows the chosen option's label rather than its value.
Installation
npx shadcn@latest add https://panelui.dev/r/web/select.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/select.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/select.jsonbunx shadcn@latest add https://panelui.dev/r/web/select.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/select instead of @/components/panelui/select. Tailwind needs one line to read the package's classes, which is on the installation page.
Install the packages it uses:
npm install @base-ui/react tailwind-variantspnpm add @base-ui/react tailwind-variantsyarn add @base-ui/react tailwind-variantsbun add @base-ui/react tailwind-variantsCopy the source into components/panelui/select.tsx:
Usage
import { Select } from "@/components/panelui/select"<Select items={plans} defaultValue="team">
<Select.Trigger>
<Select.Value placeholder="Pick a plan" />
</Select.Trigger>
<Select.Content>
{plans.map((plan) => (
<Select.Item key={plan.value} value={plan.value} description={plan.price}>
{plan.label}
</Select.Item>
))}
</Select.Content>
</Select>Composition
<Select>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.GroupLabel />
<Select.Item />
</Select.Group>
<Select.Separator />
</Select.Content>
</Select>Select.Trigger— The button that opens the list.variantisdefault, a field for a form;ghost, with no box until hovered, for a toolbar; orpill, a rounded chip with an edge. An icon put before the value is shown in it.Select.Value— The chosen option's label, orplaceholderwhile nothing is chosen. Withoutitemson the root, give it a function from a value to what to show.Select.Content— The list, opened in a layer over the page.alignItemWithTrigger={false}always opens it below the trigger instead of over it.Select.Item— One option, with a check when chosen.descriptionadds a second, quieter line, andiconan icon before the label.Select.Group— Options that belong together. A screen reader announces them by theirSelect.GroupLabel.Select.GroupLabel— The heading of a group.Select.Separator— A rule between groups.
Examples
Groups
Options under headings, with a rule between the groups.
import { Select } from '@/components/panelui/select';
const ZONES = {
Europe: [
{ value: 'Europe/Lisbon', label: 'Lisbon' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Europe/Helsinki', label: 'Helsinki' },
],
Americas: [
{ value: 'America/Bogota', label: 'Bogotá' },
{ value: 'America/Toronto', label: 'Toronto' },
{ value: 'America/Vancouver', label: 'Vancouver' },
],
Asia: [
{ value: 'Asia/Dubai', label: 'Dubai' },
{ value: 'Asia/Singapore', label: 'Singapore' },
{ value: 'Asia/Seoul', label: 'Seoul' },
],
};
const ITEMS = Object.values(ZONES).flat();
export default function SelectGrouped() {
return (
<div className="w-full max-w-xs">
<Select items={ITEMS}>
<Select.Trigger aria-label="Time zone">
<Select.Value placeholder="Choose a time zone" />
</Select.Trigger>
<Select.Content>
{Object.entries(ZONES).map(([region, zones], index) => (
<Select.Group key={region}>
{index > 0 ? <Select.Separator /> : null}
<Select.GroupLabel>{region}</Select.GroupLabel>
{zones.map((zone) => (
<Select.Item key={zone.value} value={zone.value}>
{zone.label}
</Select.Item>
))}
</Select.Group>
))}
</Select.Content>
</Select>
</div>
);
}Triggers
A field for a form, a ghost for a toolbar and a pill for a row of chips. Each takes an icon before its value.
import { ArrowDownWideNarrowIcon, LayersIcon } from 'lucide-react';
import { Select } from '@/components/panelui/select';
const SORTS = [
{ value: 'recent', label: 'Most recent' },
{ value: 'oldest', label: 'Oldest first' },
{ value: 'name', label: 'Name' },
];
const VIEWS = [
{ value: 'board', label: 'Board' },
{ value: 'list', label: 'List' },
{ value: 'calendar', label: 'Calendar' },
];
export default function SelectTriggers() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<div className="w-44">
<Select items={SORTS} defaultValue="recent">
<Select.Trigger aria-label="Sort">
<Select.Value />
</Select.Trigger>
<Select.Content>
{SORTS.map((sort) => (
<Select.Item key={sort.value} value={sort.value}>
{sort.label}
</Select.Item>
))}
</Select.Content>
</Select>
</div>
<Select items={SORTS} defaultValue="name">
<Select.Trigger variant="ghost" aria-label="Sort">
<ArrowDownWideNarrowIcon />
<Select.Value />
</Select.Trigger>
<Select.Content>
{SORTS.map((sort) => (
<Select.Item key={sort.value} value={sort.value}>
{sort.label}
</Select.Item>
))}
</Select.Content>
</Select>
<Select items={VIEWS} defaultValue="board">
<Select.Trigger variant="pill" aria-label="View">
<LayersIcon />
<Select.Value />
</Select.Trigger>
<Select.Content>
{VIEWS.map((view) => (
<Select.Item key={view.value} value={view.value}>
{view.label}
</Select.Item>
))}
</Select.Content>
</Select>
</div>
);
}API Reference
Select.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'ghost' | 'pill' | default | default is a field, ghost has no box until hovered, pill is a rounded chip. Default default. |
size | 'sm' | 'md' | 'lg' | md | Its height and text size. Default md. |
icon | ReactNode | — | Replaces the chevron at the end. Pass null for none. |
className | string | — |
Select.Value
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Select.Content
| Prop | Type | Default | Description |
|---|---|---|---|
side | BaseSelect.Positioner.Props['side'] | 'bottom' | Which side of the trigger it opens on, when it is not placed over it. Default bottom. |
align | BaseSelect.Positioner.Props['align'] | 'start' | How it lines up with the trigger along that side. Default start. |
sideOffset | number | 6 | The gap between the trigger and the list, in pixels. Default 6. |
alignItemWithTrigger | boolean | true | Whether a pointer opens it with the chosen option over the trigger. Default true. |
container | BaseSelect.Portal.Props['container'] | — | Where in the document the list is put. Default document.body. |
className | string | — |
Select.Item
| Prop | Type | Default | Description |
|---|---|---|---|
description | ReactNode | — | A second, quieter line under the label. |
icon | ReactNode | — | An icon before the label. |
className | string | — |
Select.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Select.GroupLabel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Every part also takes the props of the element it renders, and a className.
Notes
Keyboard
With the trigger focused, Enter, Space or the arrow keys open the list. The arrow keys move through the options, typing jumps to one by its first letters, Enter chooses, and Escape closes the list without changing anything.
Forms
Give the root a name and the chosen value is sent with the form it is in, as a native select's would be. required and disabled work on the root as well.
Controlled
value and onValueChange on the root hold the choice from outside. onValueChange is called with the option's value, or null when the choice is cleared.