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.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import 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-variants

Copy 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. variant is default, a field for a form; ghost, with no box until hovered, for a toolbar; or pill, a rounded chip with an edge. An icon put before the value is shown in it.
  • Select.Value — The chosen option's label, or placeholder while nothing is chosen. Without items on 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. description adds a second, quieter line, and icon an icon before the label.
  • Select.Group — Options that belong together. A screen reader announces them by their Select.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

PropTypeDefaultDescription
variant'default' | 'ghost' | 'pill'defaultdefault is a field, ghost has no box until hovered, pill is a rounded chip. Default default.
size'sm' | 'md' | 'lg'mdIts height and text size. Default md.
iconReactNode—Replaces the chevron at the end. Pass null for none.
classNamestring—

Select.Value

PropTypeDefaultDescription
classNamestring—

Select.Content

PropTypeDefaultDescription
sideBaseSelect.Positioner.Props['side']'bottom'Which side of the trigger it opens on, when it is not placed over it. Default bottom.
alignBaseSelect.Positioner.Props['align']'start'How it lines up with the trigger along that side. Default start.
sideOffsetnumber6The gap between the trigger and the list, in pixels. Default 6.
alignItemWithTriggerbooleantrueWhether a pointer opens it with the chosen option over the trigger. Default true.
containerBaseSelect.Portal.Props['container']—Where in the document the list is put. Default document.body.
classNamestring—

Select.Item

PropTypeDefaultDescription
descriptionReactNode—A second, quieter line under the label.
iconReactNode—An icon before the label.
classNamestring—

Select.Group

PropTypeDefaultDescription
classNamestring—

Select.GroupLabel

PropTypeDefaultDescription
classNamestring—

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.

On this page