Fields and selection

Select

A composable Base UI select with rich items, sizing, and positioning options.

⠋

Install

npx shadcn@latest add https://4af.selfsimilar.dev/r/select.json

Usage

import {
  Select,
  SelectContent,
  SelectItem,
  SelectSeparator,
  SelectSub,
  SelectSubContent,
  SelectSubTrigger,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

<Select size="xs" defaultValue="apple">
  <SelectTrigger variant="ghost" label="Fruit">
    <SelectValue />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectSeparator />
    <SelectSub>
      <SelectSubTrigger>More fruit</SelectSubTrigger>
      <SelectSubContent>
        <SelectItem value="banana">Banana</SelectItem>
      </SelectSubContent>
    </SelectSub>
  </SelectContent>
</Select>

API

Main exports: Select, SelectTrigger, SelectSortTrigger, SelectValue, SelectContent, SelectItem, SelectSeparator, SelectSub, SelectSubTrigger, SelectSubContent

An accessible Base UI single or multi-select with icons declarable on items so a preselected value resolves before mount, optional in-panel search, a clear footer, content sizing, and the compact sort/filter trigger.

Trigger variants

SelectTrigger and SelectSortTrigger accept variant="default" | "ghost" | "plain":

variantTrigger
defaultThe boxed field or chip.
ghostButton's muted ghost colours with the default spacing, so leading and trailing icons stay aligned.
plainNo background, border, or horizontal padding; the dropdown icon follows the label or value directly. A sort trigger's icon stays leading.

The focus ring remains for keyboard users. outline={false} is still accepted as an alias for variant="plain".

With neither label nor showValue, SelectSortTrigger shows only its glyph in a square box matching Button's icon sizes: 24, 30, or 34px for xs, sm, or md. A toolbar can switch this on the selection, so the chip names a filter only once one is set:

<SelectSortTrigger icon={<ListFilter />} showValue={value !== null} aria-label="Sort" />

SelectTrigger also accepts an optional label, rendered as a dimmed prefix before the value. For example, label="Fruit" displays Fruit: Banana.

When SelectContent has no explicit width, it follows the trigger: default and ghost triggers keep a trigger-width panel, while plain triggers use content width — the longest option label plus the reserved checkmark slot. Set width="trigger" or width="content" to override that default.

SelectContent opens with a row already active: the current selection (the most recent enabled one in a multi-select), or the first enabled row when nothing is selected. Typing moves the active row to the top enabled match. Disabled rows stay visible but are never the initial active row or the search field's Enter target; when every match is disabled, no row is active. Set autoFocusFirstItem={false} to opt out for menu-like controls such as a sort trigger.

onValueChange(value, details) can reject a selection with details.cancel(). The trigger, checked rows, and the selection offered on reopening all retain the accepted value.

<Select defaultValue="active" items={{ active: "Active", paused: "Paused" }}>
  <SelectTrigger variant="plain" label="Status">
    <SelectValue />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="active">Active</SelectItem>
    <SelectItem value="paused">Paused</SelectItem>
  </SelectContent>
</Select>

Sizing

The Select family uses the shared control scale: size="xs" is 24px for dense controls, size="sm" is 30px with compact text and glyphs, and size="md" is 34px for form rows. The selected trigger and every option row use the same size. The trigger uses a 6px button radius at xs/sm and 8px at md.

Nested select panels

Use SelectSub to put a second select panel behind a row in SelectContent. The trigger keeps the chevron after its label. ArrowRight or Enter opens the panel; ArrowLeft or Escape returns focus to the parent row. Its children are ordinary SelectItems: they use the same single- or multi-select value, check state, keyboard navigation, and close behavior as rows in the main panel. When the parent SelectContent is searchable, its field also searches these nested rows: a matching parent trigger remains in the main panel, and opening it reveals the matching child rows. Nested panels can be nested again.

In a multi-select, each submenu trigger automatically shows a dimmed count of its selected child items immediately before the chevron.

Nested panels open immediately when their parent row is hovered. Pass a positive delay to SelectSubTrigger only when a deliberate hover pause is wanted. The initially focused nested row stays unhighlighted until it is hovered or reached through keyboard navigation.

A hover-opened panel has a safe triangle. Leaving its row with the mouse keeps the panel open while the pointer travels inside the triangle between the leave point and the panel's near edge, so a diagonal path across the rows in between doesn't drop it. It closes as soon as the pointer strays out of the triangle, or once it rests inside for 300ms. closeDelay still applies when the pointer leaves the panel itself, and for non-mouse pointers.

import {
  Select,
  SelectContent,
  SelectItem,
  SelectSub,
  SelectSubContent,
  SelectSubTrigger,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

<Select defaultValue="apple" items={{ apple: "Apple", banana: "Banana", mango: "Mango" }}>
  <SelectTrigger aria-label="Fruit">
    <SelectValue />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectSub>
      <SelectSubTrigger>More fruit</SelectSubTrigger>
      <SelectSubContent>
        <SelectItem value="banana">Banana</SelectItem>
        <SelectItem value="mango">Mango</SelectItem>
      </SelectSubContent>
    </SelectSub>
  </SelectContent>
</Select>

Separators

Use SelectSeparator directly between option groups. It is backed by Base UI's select separator, carries the accessible separator role, and is not treated as a selectable row by keyboard navigation or selection.

import { SelectContent, SelectItem, SelectSeparator } from "@/components/ui/select";

<SelectContent>
  <SelectItem value="active">Active</SelectItem>
  <SelectSeparator />
  <SelectItem value="archived">Archived</SelectItem>
</SelectContent>

Sort and filter trigger

⠋

SelectSortTrigger ships in select; there is no separate select-sort item. It:

  • has a dashed border while no value is selected;
  • becomes filled and solid once a value is selected, and while its panel is open;
  • shows the selected label or labels from Select context;
  • rotates its chevron like the normal Select trigger.

It takes the same variant="ghost" and variant="plain" options as SelectTrigger.

When multiple is enabled, the same trigger becomes a filter control and supports the nested SelectSub panels described above. Nested rows participate in the multi-selection and keep the panel open while choices are toggled.

Add clearable to SelectContent for a clear footer: a compact xs ghost reset button with a SelectSeparator above it. The button is disabled when nothing is selected, and clearing keeps the panel open. Hovering it moves the active-row highlight from the list to the button.

import {
  Select,
  SelectContent,
  SelectItem,
  SelectSortTrigger,
} from "@/components/ui/select";

<Select<string>>
  <SelectSortTrigger label="Status" variant="plain" />
  <SelectContent clearable autoFocusFirstItem={false}>
    <SelectItem value="active">Active</SelectItem>
    <SelectItem value="paused">Paused</SelectItem>
  </SelectContent>
</Select>

footerActions adds buttons for actions on the selection, such as Archive or "Select all". They share the footer row evenly and take the same hover highlight as clearable. Each takes a label, an optional leading icon, checked to show a tick, and keepOpen to leave the panel open. clearable adds its button after them.

indicator={false} hides the checkbox or tick; it does not prevent selection.

<Select<string> multiple value={tags} onValueChange={setTags}>
  <SelectSortTrigger label="Tags" variant="plain" />
  <SelectContent
    searchable
    searchPlaceholder="Search tags"
    footerActions={[
      { key: "archive", label: "Archive", icon: <Archive />, checked: archived, onSelect: archive },
      { key: "clear", label: "Clear tags", icon: <X />, disabled: tags.length === 0, onSelect: clearTags },
    ]}
  >
    {options.map((option) => (
      <SelectItem key={option} value={option}>{option}</SelectItem>
    ))}
  </SelectContent>
</Select>

Preselected icons

Declare option icons in the root items collection when a closed, preselected trigger must show its icon on first paint:

<Select
  items={[{ value: "sans", label: "Sans-serif", icon: <Type /> }]}
  defaultValue="sans"
>

SelectItem's own icon only registers once the panel has mounted, so a closed trigger can't show it on first load. Use iconForValue when the icon is computed rather than declared. Resolution order is the mounted item, then iconForValue, then items.