Skip to content
Component installs need the registry setup
VegaStack Design

Select

A dropdown for choosing one option — a trigger with the value and a chevron, a grouped scrollable popup, and item-aligned positioning.

Status
stable
Since
0.1.0
Accessibility pattern
APG listbox

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Select before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify select

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/select

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/item, @vegastack/spinner.

Usage

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

<Select items={items}>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Theme" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectItem value="light">Light</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>;

Anatomy

Select
SelectTrigger — data-slot="select-trigger"
SelectValue — data-slot="select-value"
SelectContent — data-slot="select-content" | "select-status"
SelectGroup — data-slot="select-group"
SelectLabel — data-slot="select-label"
SelectItem — data-slot="select-item"
SelectSeparator — data-slot="select-separator"
SelectScrollUpButton — data-slot="select-scroll-up-button"
SelectScrollDownButton — data-slot="select-scroll-down-button"

Examples

Composition

Select
├── SelectTrigger
│   └── SelectValue
└── SelectContent
    ├── SelectGroup
    │   ├── SelectLabel
    │   └── SelectItem
    ├── SelectSeparator
    └── SelectGroup

Align Item With Trigger

alignItemWithTrigger on SelectContent decides whether the popup positions so the selected item sits over the trigger (true, the default) or aligns to the trigger's edge (false).

Toggle to align the item with the trigger.

Groups

SelectGroup, SelectLabel and SelectSeparator organise the popup.

Scrollable

With more items than fit, the popup scrolls and the scroll arrows appear at its edges.

Disabled

disabled on the Select disables the whole control; on a SelectItem it disables one row. A disabled item keeps its pointer events (FRM-4), so a Tooltip can explain why it is unavailable.

Invalid

Add aria-invalid to the SelectTrigger and data-invalid to the Field. FieldError carries the message with a leading icon, so the error is never signalled by colour alone (A11Y-8).

RTL

The trigger's padding, the item's check indicator and the popup's origin are all written in logical properties, so a right-to-left document mirrors the whole control.

Sizes

size on the trigger takes sm (28px) and default (32px).

Inline trigger

The default trigger fills its parent, like every form control. For a Select that sits inline — in a table row, a list item or a toolbar — use variant="ghost": it sizes to its content, has no border at rest, and shows the border on hover only — never on focus or while the popup is open (FOC-14). Pair it with size="sm" in dense rows, and give it an accessible name.

Renew the SSL certificate

Two-line options

An option that needs a second line composes Item's parts: ItemContent › ItemTitle + ItemDescription. SelectItem provides Item's description context, so the description's id lands on the option's aria-describedby and a screen reader hears it as the option's description (API-19). A one-line option links nothing.

Long values

A trigger is one line and cuts a long value off. When the value is a long user-entered name — a product, a task — give SelectTrigger the wrap prop: its fixed height becomes a minimum (one line still measures h-8, or h-7 at size="sm") and the value runs onto as many lines as it needs. wrap on SelectContent lets each option wrap too, instead of being cut at the popup's edge, which is as wide as the trigger. A long word with no spaces breaks anywhere. Both write data-wrap (API-35). Keep short picklists and inline triggers on one line.

Loading and errors

Options that come from a server get a status row in the popup: SelectContent loading shows a spinner, and error (with onRetry) shows the failure with a Try again button, so a failed read never looks like an empty list.

<SelectContent loading={isPending} error={failed} onRetry={refetch}>
  {options.map((o) => (
    <SelectItem key={o.id} value={o.id}>
      {o.name}
    </SelectItem>
  ))}
</SelectContent>

States

The trigger's rest, invalid and disabled chrome, closed.

Playground

Try both trigger sizes with the disabled and invalid states, then copy the generated JSX.

const fonts = [
  { value: "sans", label: "Sans-serif" },
  { value: "serif", label: "Serif" },
  { value: "mono", label: "Monospace" },
];

<Select items={fonts} defaultValue="serif">
  <SelectTrigger aria-label="Font family">
    <SelectValue placeholder="Select a font" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectItem value="sans">Sans-serif</SelectItem>
      <SelectItem value="serif">Serif</SelectItem>
      <SelectItem value="mono">Monospace</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

API Reference

PropTypeDefaultDescription
size"default" | "sm"—
variant"ghost" | "outline"—
wrapboolean—
PropTypeDefaultDescription
errorReact.ReactNode—API-38: the options could not be fetched — an error row (error itself when a string, else errorLabel) with a Try again button when onRetry is set. Wins over loading.
errorLabelReact.ReactNode"Couldn't load the options."
loadingbooleanfalseAPI-38: the options are being fetched — a spinner row (loadingLabel) so an async list is never an empty popup.
loadingLabelReact.ReactNode"Loading…"
onRetry(() => void)—API-38: fetch the options again.
retryLabelstring"Try again"
wrapboolean—

Select, SelectValue, SelectGroup, SelectLabel, SelectItem and SelectSeparator add no props of their own — each accepts everything the Base UI part accepts.

Accessibility

  • Renders Base UI's Select: the trigger is a <button role="combobox"> with aria-expanded and aria-controls, and the popup is a role="listbox" of role="option" rows with type-ahead, ↑/↓, Home/End and Esc.
  • Focus on the trigger shows base.css's global background tint on :focus-visible, because the trigger is a button rather than a text field (FOC-4); the border never changes colour on focus or while the popup is open (FOC-14).
  • The popup portals out of the trigger's subtree, so SelectContent re-applies the theme scope on the positioner inside the portal (OVL-13) — a dark or scoped surface survives the portal.
  • Items carry the hand cursor from the one global rule in base.css (INT-1); upstream's local cursor-default is removed.
  • Give the trigger an accessible name — a FieldLabel in the same Field (wired automatically), or an aria-label for an inline ghost trigger.
ContractStates tested
Behaviourdefault, open, disabled, invalid, selected
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, invalid, disabled
Visualdefault, hover, focus, open, invalid, disabled

Do / Don't

Do
Use a Select for one value out of a known, groupable set that benefits from custom item markup.
Don't
Use a Select where the user needs to search or the list is unbounded — that is a Combobox.

Deviations

Upstream's file plus packages/ui/upstream/patches/select.patch. Every hunk:

  • API-19 — SelectItem provides Item's ItemDescriptionContext and points aria-describedby at an ItemDescription once one registers inside the option, so a two-line option reads its second line as the option's accessible description (Two-line options). A one-line option is unchanged.
  • FOC-1, FOC-3, FOC-4, FOC-6 — the trigger's outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 is removed outright, outline-none included, so the global :focus-visible tint paints and the border stays border-input (FOC-14 retired the focus:border-ring/70 tint).
  • FOC-14 — the invalid border is aria-invalid:border-destructive (and its dark counterpart), held in every state, focused or not; the aria-invalid halo rings are removed with the rest.
  • INT-1 — cursor-default is removed from SelectItem, so a row shows the hand cursor the one global rule gives every [role="option"]. The two scroll arrows keep theirs: base.css names them as a sanctioned non-pointer cursor.
  • FRM-4 — data-disabled:pointer-events-none is dropped from SelectItem, so a disabled row stays hoverable and a Tooltip can explain it.
  • OVL-13 — SelectContent reads useInternalThemeScope() and attaches it to the positioner inside the portal; tooling/verify-portal-theme-scope.mjs inventories this host.
  • BRD-1 — SelectContent draws a real 1px border border-border instead of upstream's ring-1 ring-foreground/10 box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves.
  • API-24 — SelectTrigger takes variant="outline" | "ghost" (reflected as data-variant). The default trigger is w-full, where upstream's is w-fit, so a Select takes its width from its parent like every other form control; ghost keeps w-fit, hides its border at rest and shows it on hover only (hover:border-input; FOC-14 dropped data-popup-open:border-input).
  • API-26 — NO HUNK for the wiring: Base UI's Select reads the Field context itself, so inside a Field the trigger takes its label, the rendered description and error ids, and aria-invalid.
  • API-35 — SelectTrigger takes wrap (reflected as data-wrap): the fixed height becomes a min-h-* and the value wraps (whitespace-normal wrap-anywhere). SelectContent takes wrap too, and under it SelectItem's text drops whitespace-nowrap and shrink-0 (Long values). MK, 2026-10-13.
  • DOC-2 — cn is imported from @vegastack/design.
  • API-38 — SelectContent takes loading, error and onRetry: a status row after the list — a spinner, or the failure with Try again. MK, 2026-10-26.

On this page