Skip to content

Select

Lets people choose one or several options from a list that stays out of the way until it's needed.

SourceBase UIRegistry
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

  1. 1TriggerLooks like an input and shares its heights, so a select lines up with text fields in a form.
  2. 2ValueThe selected option's label — or a placeholder in muted text when nothing is chosen.
  3. 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.json

Usage

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.

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.

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

Instance sizeSmall · 1 vCPUMedium · 2 vCPULarge · 4 vCPU
DoOptions ordered by magnitude, with short, parallel labels.
large-4cpuSmall instance (1 vCPU)MEDIUM
Don’tArbitrary order and inconsistent labels make people read every option.

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.

KeyBehavior
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 FieldLabel at the trigger with htmlFor, or give the trigger an aria-label when the context makes the purpose obvious visually (for example a toolbar).
  • Boundaries. The trigger's border uses the input token, 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} on Select if the page must stay interactive.

API reference

Select

The root. Doesn't render an element. Accepts every prop of Base UI's Select.Root.

PropTypeDefault
items

The options' labels. Lets SelectValue render a label instead of a raw value; a null-valued item provides the placeholder.

Record<string, ReactNode> | { label, value }[] | Group[]No default
value

The selected value. Use with onValueChange for a controlled select.

Value | Value[] | nullNo default
defaultValue

The initially selected value when uncontrolled.

Value | Value[] | nullNo default
onValueChange

Called when the selection changes.

(value, details) => voidNo default
multiple

Allows several options to be selected; the value becomes an array.

booleanfalse
itemToStringValue

Serializes object values for form submission.

(value) => stringNo default
itemToStringLabel

Turns object values into text for the trigger.

(value) => stringNo default
isItemEqualToValue

Custom equality for object values. Defaults to Object.is.

(itemValue, value) => booleanNo default
name

Submits the value under this name with a form.

stringNo default
required

Requires a value before the form can be submitted.

booleanfalse
disabled

Disables the whole select.

booleanfalse
readOnly

Shows the value but prevents changing it.

booleanfalse
open / defaultOpen / onOpenChange

Controls the popup's visibility.

boolean / boolean / (open, details) => voidNo default
modal

Locks page scroll and outside interaction while open.

booleantrue

SelectTrigger

The button that shows the value and opens the list. Renders a <button>.

PropTypeDefault
size

Height and padding: 32, 36 or 40px — shared with Input and Button.

"sm" | "default" | "lg""default"
aria-invalid

Shows the error state. Pair with data-invalid on the surrounding Field.

booleanNo default

SelectValue

Displays the selection inside the trigger. Renders a <span>.

PropTypeDefault
children

Custom rendering of the selected value, e.g. a summary for multiple selection.

ReactNode | (value) => ReactNodeNo default
placeholder

Shown when nothing is selected. A null-valued item in items takes precedence.

ReactNodeNo default

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.

PropTypeDefault
alignItemWithTrigger

Overlaps the trigger so the selected option sits on top of it. Set false for a dropdown that opens beside the trigger.

booleantrue
side

Preferred side when not aligned with the trigger.

"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""bottom"
sideOffset

Gap between trigger and popup, in pixels.

number6
align

Alignment along the side.

"start" | "center" | "end""center"
alignOffset

Offset along the alignment axis, in pixels.

number0

SelectItem

An option. Renders a <div> with role="option".

PropTypeDefault
valuerequired

The value this option represents.

anyNo default
disabled

Prevents highlighting and choosing the option.

booleanfalse
label

Text used for typeahead when the content isn't plain text.

stringNo default

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.