Select
A dropdown for choosing one option — a trigger with the value and a chevron, a grouped scrollable popup, and item-aligned positioning.
- Status
- 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 selectInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/selectRun 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
Examples
Composition
Select
├── SelectTrigger
│ └── SelectValue
└── SelectContent
├── SelectGroup
│ ├── SelectLabel
│ └── SelectItem
├── SelectSeparator
└── SelectGroupAlign 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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
size | "default" | "sm" | — | |
variant | "ghost" | "outline" | — | |
wrap | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
error | React.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. |
errorLabel | React.ReactNode | "Couldn't load the options." | |
loading | boolean | false | API-38: the options are being fetched — a spinner row (loadingLabel) so an async list is
never an empty popup. |
loadingLabel | React.ReactNode | "Loading…" | |
onRetry | (() => void) | — | API-38: fetch the options again. |
retryLabel | string | "Try again" | |
wrap | boolean | — |
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">witharia-expandedandaria-controls, and the popup is arole="listbox"ofrole="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
SelectContentre-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 localcursor-defaultis removed. - Give the trigger an accessible name — a
FieldLabelin the sameField(wired automatically), or anaria-labelfor an inlineghosttrigger.
| Contract | States tested |
|---|---|
| Behaviour | default, open, disabled, invalid, selected |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, invalid, disabled |
| Visual | default, hover, focus, open, invalid, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/select.patch. Every hunk:
- API-19 —
SelectItemprovides Item'sItemDescriptionContextand pointsaria-describedbyat anItemDescriptiononce 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/50is removed outright,outline-noneincluded, so the global:focus-visibletint paints and the border staysborder-input(FOC-14 retired thefocus:border-ring/70tint). - FOC-14 — the invalid border is
aria-invalid:border-destructive(and its dark counterpart), held in every state, focused or not; thearia-invalidhalo rings are removed with the rest. - INT-1 —
cursor-defaultis removed fromSelectItem, so a row shows the hand cursor the one global rule gives every[role="option"]. The two scroll arrows keep theirs:base.cssnames them as a sanctioned non-pointer cursor. - FRM-4 —
data-disabled:pointer-events-noneis dropped fromSelectItem, so a disabled row stays hoverable and a Tooltip can explain it. - OVL-13 —
SelectContentreadsuseInternalThemeScope()and attaches it to the positioner inside the portal;tooling/verify-portal-theme-scope.mjsinventories this host. - BRD-1 —
SelectContentdraws a real 1pxborder border-borderinstead of upstream'sring-1 ring-foreground/10box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves. - API-24 —
SelectTriggertakesvariant="outline" | "ghost"(reflected asdata-variant). The default trigger isw-full, where upstream's isw-fit, so a Select takes its width from its parent like every other form control;ghostkeepsw-fit, hides its border at rest and shows it on hover only (hover:border-input; FOC-14 droppeddata-popup-open:border-input). - API-26 — NO HUNK for the wiring: Base UI's
Selectreads theFieldcontext itself, so inside aFieldthe trigger takes its label, the rendered description and error ids, andaria-invalid. - API-35 —
SelectTriggertakeswrap(reflected asdata-wrap): the fixed height becomes amin-h-*and the value wraps (whitespace-normal wrap-anywhere).SelectContenttakeswraptoo, and under itSelectItem's text dropswhitespace-nowrapandshrink-0(Long values). MK, 2026-10-13. - DOC-2 —
cnis imported from@vegastack/design. - API-38 —
SelectContenttakesloading,errorandonRetry: a status row after the list — a spinner, or the failure with Try again. MK, 2026-10-26.