A single-select control with two render paths behind one API: a rich Popover-API listbox (the default) whose options carry icons, badges and a selected mark, or a native <select> for flat, OS-native cases. The rich control is a framed field on the same geometry as Input; its panel and rows resolve the shared dropdown tokens, so a Select listbox and a Dropdown menu are the same surface. Positioned with CSS anchor positioning, wired by the component itself; keyboard, typeahead, focus and form submission are owned by the headless primitive.
Playground
Preview
Light
Dark
System
import{Select,SelectTrigger,SelectValue,SelectIcon,SelectContent,SelectItem,SelectItemIndicator,SelectItemLeading,SelectItemLabel}from"@/components/ui/select";import{Check,ChevronDown,Sun}from"@primitiv-ui/icons";<SelectdefaultValue="light"><SelectTriggersize="md"><SelectValueplaceholder="Choose a theme..."/><SelectIcon><ChevronDown /></SelectIcon></SelectTrigger><SelectContentsize="md"placement="bottom-start"><SelectItemvalue="light"><SelectItemIndicator><Check /></SelectItemIndicator><SelectItemLeading><Sun /></SelectItemLeading><SelectItemLabel>Light</SelectItemLabel></SelectItem>{/* ... */}</SelectContent></Select>
import{Select}from"@primitiv-ui/react";import{Check,ChevronDown,Sun}from"@primitiv-ui/icons";<Select.RootdefaultValue="light"><Select.Trigger><Select.Valueplaceholder="Choose a theme..."/><Select.Icon><ChevronDown /></Select.Icon></Select.Trigger><Select.Content><Select.Itemvalue="light"><Select.ItemIndicator><Check /></Select.ItemIndicator><Select.ItemLeading><Sun /></Select.ItemLeading><Select.ItemLabel>Light</Select.ItemLabel></Select.Item>{/* ... */}</Select.Content></Select.Root>
Density is set by a data-density ancestor — the Context system, not a Select prop.
Installation
npx primitiv add select
pnpm dlx primitiv add select
yarn dlx primitiv add select
bunx primitiv add select
Import
import{Select}from"@/components/ui/select";
Copied into your project as .primitiv-select — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Nine parts, but the tree differs by render path — five of them render nothing at all under native. Switch the tab to compare.
Rich
<Select><SelectTrigger><SelectValueplaceholder="Choose a framework..."/><SelectIcon/></SelectTrigger><SelectContent><SelectGrouplabel="Stable"><SelectItemvalue="react"><SelectItemIndicator/> React</SelectItem></SelectGroup><SelectSeparator/></SelectContent></Select>
<Select.Root><Select.Trigger><Select.Valueplaceholder="Choose a framework..."/><Select.Icon/></Select.Trigger><Select.Content><Select.Grouplabel="Stable"><Select.Itemvalue="react"><Select.ItemIndicator/> React</Select.Item></Select.Group><Select.Separator/></Select.Content></Select.Root>
Native
<Selectnative><SelectPlaceholder>Choose a framework...</SelectPlaceholder><SelectGrouplabel="Stable"><SelectItemvalue="react">React</SelectItem></SelectGroup></Select>// Renders nothing under native:// SelectTrigger — the root is the control, so there is nothing to wrap// SelectValue — the platform draws the selected option// SelectContent — the platform owns the popup// SelectIcon — the stylesheet paints the chevron itself here// SelectItemIndicator — an <option> cannot contain an element
<Select.Rootnative><Select.Placeholder>Choose a framework...</Select.Placeholder><Select.Grouplabel="Stable"><Select.Itemvalue="react">React</Select.Item></Select.Group></Select.Root>// Renders nothing under native:// Select.Trigger — the root is the control, so there is nothing to wrap// Select.Value — the platform draws the selected option// Select.Content — the platform owns the popup// Select.Icon — the stylesheet paints the chevron itself here// Select.ItemIndicator — an <option> cannot contain an element
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Select.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When true, Root delegates to a single consumer-supplied element
(expected to render a <select>) and merges its own props onto it
via the Slot pattern. Placeholder-detection inside Root
walks direct children only in this mode, so the asChild +
placeholder combination requires the consumer to set
defaultValue="" explicitly.
children
ReactNode
—
headless
Content of the select — typically SelectOption, SelectGroup,
and optionally a leading SelectPlaceholder.
defaultOpen
boolean
false
headless
Rich mode only — whether the listbox popover is open on first render.
Ignored under native (the browser owns the popup). Uncontrolled: pass
this (or omit for closed) and let the component manage the state.
defaultValue
string
—
headless
Value of the option selected on first render. When omitted and a
SelectPlaceholder is present among Root's direct children,
Root infers "" automatically so the placeholder is the initial
selection.
Forbidden in controlled mode — use value instead.
native
boolean
false
headless
Selects the render path:
- false (the default) — the rich render path: a fully-styleable
Popover-API listbox built from SelectTrigger,
SelectValue,
SelectContent and
SelectItem. Icons and other rich content on an
item render as authored.
- true — the native render path: a thin wrapper over a real
<select> / <option> / <optgroup>, for flat, OS-native cases
(mobile wheel pickers, maximum-compatibility forms). Under native,
SelectItem renders an <option> from its
string/number children only — element children (icons, indicators)
are dropped.
Both modes share the same value / onValueChange / disabled / form
name API.
onChange
ChangeEventHandler<HTMLSelectElement>
—
headless
Native change handler. Fires alongside onValueChange whenever the
user picks a different option. Use this when you want the raw
ChangeEvent (e.g. to inspect event.target.validity).
onOpenChange
(open: boolean) => void
—
headless
Rich mode only — called with the next open state whenever the listbox
opens or closes (trigger click, selection, Escape, light-dismiss).
onValueChange
(value: string) => void
—
headless
Called with the new option value whenever the user changes the
selection. Optional in uncontrolled mode.
Called with the new option value whenever the user changes the
selection. Required in controlled mode.
open
boolean
—
headless
Rich mode only — the controlled open state of the listbox popover. Pass
together with SelectRootBaseProps.onOpenChange`onOpenChange`
to own the state from the parent.
ref
Ref<HTMLSelectElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLSelectElement.
value
string
—
headless
Forbidden in uncontrolled mode — use defaultValue instead.
The currently selected option value. Must be kept in sync by the
parent via onValueChange.
Select.Trigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the composed child element instead of a <button>, merging the
trigger's ARIA attributes and click handler via the Slot
pattern.
children
ReactNode
—
headless
Trigger content — typically a SelectValue.
ref
Ref<HTMLButtonElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLButtonElement.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control scale; data-density scales the sizing within each size. Re-points the frame (height/padding/radius/gap/icon) and the typography. On SelectContent the same axis re-points the panel + row sizing knobs.
mode
"rich" | "native"
rich
styled
Which render path the frame is dressing. rich lays the control out as [leading][value][chevron]; native leaves the inner layout and the popup to the platform, but paints its own chevron (matching the rich one's shape/size/inset) over the UA's default arrow.
Which side of the trigger the panel opens on, via anchor-positioning insets on SelectContent. The anchor-name / position-anchor pair is wired by the component from a useId-derived ident, so nothing is required of the consumer.
Select.Value
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
placeholder
ReactNode
—
headless
Shown when no value is selected (e.g. "Select a framework...").
ref
Ref<HTMLSpanElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLSpanElement.
Select.Placeholder
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The placeholder hint text shown in the closed select before the user
makes a selection (e.g. "Choose a fruit...").
ref
Ref<HTMLOptionElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLOptionElement.
Select.Content
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the composed child element instead of a <div>, merging the
listbox props via the Slot pattern.
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLDivElement.
Select.Item
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
value* (required)
string
—
headless
The value this item represents; committed as the Select's value when
chosen, and matched against the current value for the selected state.
children
ReactNode
—
headless
The item content. In native mode only its string/number parts are
kept as the <option> text (element children are dropped); in rich mode
arbitrary content (icons, indicators) renders as authored.
disabled
boolean
—
headless
Marks the item unselectable while still visible.
Select.ItemIndicator
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the composed child element instead of a <span> via the
Slot pattern.
children
ReactNode
—
headless
The mark to render (e.g. a checkmark glyph or icon).
forceMount
boolean
false
headless
Keep the indicator mounted even when the item is not selected (it still
exposes data-state="unchecked"), useful for CSS enter/exit animation.
ref
Ref<HTMLSpanElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLSpanElement.
Select.Group
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
label* (required)
string
—
headless
The group heading. In native mode it is the <optgroup>'s label
attribute; in rich mode it becomes the <div role="group">'s
aria-label. Either way it is the group's accessible name. Required —
a group without one is inaccessible.
children
ReactNode
—
headless
The SelectItem elements belonging to this group.
Select.Separator
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the composed child element (with separator semantics) instead of
the default <div role="separator">.
children
ReactNode
—
headless
Optional content; separators are usually empty, and only render (via
the Slot pattern) when asChild composes onto a custom element.
ref
Ref<HTMLDivElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying HTMLDivElement.
Styling contract
58 CSS custom properties on .primitiv-select — mode-agnostic. These names are the stable surface; the values are not.
While the listbox is open. Disabled options are skipped by arrows and typeahead; on open, focus moves to the selected option, or the first enabled one. Under native every key below belongs to the platform instead.
Key
Behaviour
ArrowDown / ArrowUp
Move focus to the next / previous option (wraps).
Home / End
First / last option.
Enter / Space
Select the focused option and close.
Escape
Close and return focus to the trigger.
printable character
Typeahead — focus the next option matching the prefix.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
SelectTrigger
className:.primitiv-select
Attribute
Value
When
aria-expanded
true | false
whether the listbox is open
aria-invalid
true
invalid
data-disabled
""
disabled — on the native `<select>`, which shares this class
Select.Trigger
Attribute
Value
When
aria-expanded
true | false
whether the listbox is open
aria-invalid
true
invalid
data-disabled
""
disabled — on the native `<select>`, which shares this class
SelectValue
className:.primitiv-select__value
Attribute
Value
When
data-placeholder
""
nothing selected
Select.Value
Attribute
Value
When
data-placeholder
""
nothing selected
SelectItem
className:.primitiv-select__item
Attribute
Value
When
data-state
checked | unchecked
whether this option is selected
data-disabled
""
the option is disabled
Select.Item
Attribute
Value
When
data-state
checked | unchecked
whether this option is selected
data-disabled
""
the option is disabled
SelectItemIndicator
className:.primitiv-select__item-indicator
Attribute
Value
When
data-state
checked | unchecked
whether its option is selected
Select.ItemIndicator
Attribute
Value
When
data-state
checked | unchecked
whether its option is selected
Accessibility
Rich mode renders a real listbox: role="listbox" on the panel and role="option" on each row, with aria-selected tracking the value and aria-haspopup="listbox" on the trigger.
The panel lives in the top layer via the Popover API, so it escapes ancestor overflow and stacking contexts — and light dismiss (click outside, Escape) is handled by the browser rather than a hand-rolled outside-pointerdown listener.
A closed Select has no listbox in the accessibility tree at all: the panel unmounts while closed rather than being hidden, so assistive tech is never offered a control that is not there.
A hidden native <select> is rendered alongside rich mode, so the control takes part in normal form submission without the listbox having to fake it.
Native mode is a real <select>, so it inherits the platform picker and every OS accessibility affordance for free — including the mobile wheel and any assistive tech that special-cases the element.
The cursor row is tracked separately from the selected row, so moving focus with the arrows does not change the value until you commit it.
A rich trigger is named by its own content, but a native root has no Trigger to name it — pass aria-label (or wire a Field label), because an unlabelled <select> is a genuine failure rather than a lint nit.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Rich mode (the default)
A Popover-API listbox. Each option carries a leading icon and a selected mark, and Select.Value mirrors the chosen row — icon included — straight into the closed trigger, so the trigger needs no icon of its own. The panel lives in the top layer, so it escapes ancestor overflow and stacking contexts, and it anchors itself to its trigger with no anchor-name to wire.
Light
Dark
System
import{Select,SelectTrigger,SelectValue,SelectIcon,SelectContent,SelectItem,SelectItemIndicator,SelectItemLeading,SelectItemLabel}from"@/components/ui/select";import{Check,ChevronDown,Sun}from"@primitiv-ui/icons";<divdata-density="comfortable"><SelectdefaultValue="light"><SelectTrigger><SelectValueplaceholder="Choose a theme..."/><SelectIcon><ChevronDown /></SelectIcon></SelectTrigger><SelectContent><SelectItemvalue="light"><SelectItemIndicator><Check /></SelectItemIndicator><SelectItemLeading><Sun /></SelectItemLeading><SelectItemLabel>Light</SelectItemLabel></SelectItem>{/* ... */}</SelectContent></Select></div>
import{Select}from"@primitiv-ui/react";import{Check,ChevronDown,Sun}from"@primitiv-ui/icons";<divdata-density="comfortable"><Select.RootdefaultValue="light"><Select.Trigger><Select.Valueplaceholder="Choose a theme..."/><Select.Icon><ChevronDown /></Select.Icon></Select.Trigger><Select.Content><Select.Itemvalue="light"><Select.ItemIndicator><Check /></Select.ItemIndicator><Select.ItemLeading><Sun /></Select.ItemLeading><Select.ItemLabel>Light</Select.ItemLabel></Select.Item>{/* ... */}</Select.Content></Select.Root></div>
Native mode
native renders a real <select> / <option>, for flat lists, OS wheel pickers and maximum-compatibility forms. The composition is genuinely different: items sit directly on the root, with no Trigger or Content — the root is the control, so those parts have nothing to wrap. Element children on an Item are dropped too, and only its text survives as the option label, because an <option> cannot contain elements.
import{Select,SelectItem}from"@/components/ui/select";<divdata-density="comfortable"><SelectnativedefaultValue="react"aria-label="Choose a framework"><SelectItemvalue="react">React</SelectItem><SelectItemvalue="vue">Vue</SelectItem><SelectItemvalue="solid">Solid</SelectItem></Select></div>
import{Select}from"@primitiv-ui/react";<divdata-density="comfortable"><Select.RootnativedefaultValue="react"aria-label="Choose a framework"><Select.Itemvalue="react">React</Select.Item><Select.Itemvalue="vue">Vue</Select.Item><Select.Itemvalue="solid">Solid</Select.Item></Select.Root></div>
Grouped options
SelectGroup takes a required label — the <optgroup> label under native, the group's aria-label in rich mode. Either way it is the group's accessible name, so a group without one is inaccessible. It is a string prop rather than JSX children, which sidesteps the text-vs-element extraction problem Item has under native. SelectSeparator divides groups and is skipped by keyboard navigation.
Pass value with onValueChange and the parent owns the selection. defaultValue is then forbidden at the type level — a discriminated union enforces it, so only one shape compiles. That constraint is why you will not find it in the props table above: a union collapses to a flat prop list when the types are extracted, so it has to be stated here.
React
Vue
Solid
import{ useState }from"react";import{Select,SelectTrigger,SelectValue,SelectIcon}from"@/components/ui/select";import{ChevronDown}from"@primitiv-ui/icons";const[value, setValue]=useState("react");<Selectvalue={value}onValueChange={setValue}><SelectTrigger><SelectValueplaceholder="Choose a framework..."/><SelectIcon><ChevronDown /></SelectIcon></SelectTrigger>{/* ... */}</Select>// current value: "react"
import{ useState }from"react";import{Select}from"@primitiv-ui/react";import{ChevronDown}from"@primitiv-ui/icons";const[value, setValue]=useState("react");<Select.Rootvalue={value}onValueChange={setValue}><Select.Trigger><Select.Valueplaceholder="Choose a framework..."/><Select.Icon><ChevronDown /></Select.Icon></Select.Trigger>{/* ... */}</Select.Root>// current value: "react"