Skip to content
Primitiv home
Framework
Consumption mode

Select

stableSource Figma

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

Density

Preview

Light
Dark
System
Size
Mode
Placement
import { Select, SelectTrigger, SelectValue, SelectIcon, SelectContent, SelectItem, SelectItemIndicator, SelectItemLeading, SelectItemLabel } from "@/components/ui/select";import { Check, ChevronDown, Sun } from "@primitiv-ui/icons";
<Select defaultValue="light">  <SelectTrigger size="md">    <SelectValue placeholder="Choose a theme..." />    <SelectIcon><ChevronDown /></SelectIcon>  </SelectTrigger>  <SelectContent size="md" placement="bottom-start">    <SelectItem value="light">      <SelectItemIndicator><Check /></SelectItemIndicator>      <SelectItemLeading><Sun /></SelectItemLeading>      <SelectItemLabel>Light</SelectItemLabel>    </SelectItem>    {/* ... */}  </SelectContent></Select>

Density is set by a data-density ancestor — the Context system, not a Select prop.

Installation

npx 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

Rich

<Select>  <SelectTrigger>    <SelectValue placeholder="Choose a framework..." />    <SelectIcon />  </SelectTrigger>
  <SelectContent>    <SelectGroup label="Stable">      <SelectItem value="react">        <SelectItemIndicator />        React      </SelectItem>    </SelectGroup>
    <SelectSeparator />  </SelectContent></Select>

Native

<Select native>  <SelectPlaceholder>Choose a framework...</SelectPlaceholder>
  <SelectGroup label="Stable">    <SelectItem value="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

Props

Select.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen 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.
childrenReactNodeheadlessContent of the select — typically SelectOption, SelectGroup, and optionally a leading SelectPlaceholder.
defaultOpenbooleanfalseheadlessRich 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.
defaultValuestringheadlessValue 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.
nativebooleanfalseheadlessSelects 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.
onChangeChangeEventHandler<HTMLSelectElement>headlessNative 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) => voidheadlessRich mode only — called with the next open state whenever the listbox opens or closes (trigger click, selection, Escape, light-dismiss).
onValueChange(value: string) => voidheadlessCalled 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.
openbooleanheadlessRich mode only — the controlled open state of the listbox popover. Pass together with SelectRootBaseProps.onOpenChange`onOpenChange` to own the state from the parent.
refRef<HTMLSelectElement>headlessAllows 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.
valuestringheadlessForbidden 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element instead of a <button>, merging the trigger's ARIA attributes and click handler via the Slot pattern.
childrenReactNodeheadlessTrigger content — typically a SelectValue.
refRef<HTMLButtonElement>headlessAllows 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"mdstyledControl 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"richstyledWhich 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.
placement"bottom-start" | "bottom-end" | "top-start" | "top-end"bottom-startstyledWhich 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.

PropTypeDefaultFromDescription
placeholderReactNodeheadlessShown when no value is selected (e.g. "Select a framework...").
refRef<HTMLSpanElement>headlessAllows 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.

PropTypeDefaultFromDescription
childrenReactNodeheadlessThe placeholder hint text shown in the closed select before the user makes a selection (e.g. "Choose a fruit...").
refRef<HTMLOptionElement>headlessAllows 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element instead of a <div>, merging the listbox props via the Slot pattern.
childrenReactNodeheadlessListbox content — SelectItem / SelectGroup elements.
refRef<HTMLDivElement>headlessAllows 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.

PropTypeDefaultFromDescription
value (required)stringheadlessThe value this item represents; committed as the Select's value when chosen, and matched against the current value for the selected state.
childrenReactNodeheadlessThe 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.
disabledbooleanheadlessMarks the item unselectable while still visible.

Select.ItemIndicator

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element instead of a <span> via the Slot pattern.
childrenReactNodeheadlessThe mark to render (e.g. a checkmark glyph or icon).
forceMountbooleanfalseheadlessKeep the indicator mounted even when the item is not selected (it still exposes data-state="unchecked"), useful for CSS enter/exit animation.
refRef<HTMLSpanElement>headlessAllows 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.

PropTypeDefaultFromDescription
label (required)stringheadlessThe 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.
childrenReactNodeheadlessThe SelectItem elements belonging to this group.

Select.Separator

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element (with separator semantics) instead of the default <div role="separator">.
childrenReactNodeheadlessOptional content; separators are usually empty, and only render (via the Slot pattern) when asChild composes onto a custom element.
refRef<HTMLDivElement>headlessAllows 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

Control frame

--primitiv-select-bg--primitiv-select-fg--primitiv-select-placeholder-color--primitiv-select-border-color--primitiv-select-border-color-hover--primitiv-select-border-color-focus--primitiv-select-border-color-invalid--primitiv-select-border-width--primitiv-select-radius--primitiv-select-height--primitiv-select-padding-inline--primitiv-select-gap--primitiv-select-icon-size--primitiv-select-icon-color--primitiv-select-native-chevron--primitiv-select-font-family--primitiv-select-font-size--primitiv-select-font-weight--primitiv-select-line-height--primitiv-select-bg-disabled--primitiv-select-fg-disabled--primitiv-select-offset--primitiv-select-label-ink-slack

Panel

--primitiv-select-content-surface--primitiv-select-content-fg--primitiv-select-content-border-width--primitiv-select-content-border-color--primitiv-select-content-shadow--primitiv-select-content-radius--primitiv-select-content-padding-block--primitiv-select-content-padding-inline--primitiv-select-content-min-inline-size--primitiv-select-content-max-block-size

Item

--primitiv-select-item-height--primitiv-select-item-padding-inline--primitiv-select-item-gap--primitiv-select-item-radius--primitiv-select-item-font-size--primitiv-select-item-line-height--primitiv-select-item-icon-size--primitiv-select-item-font-family--primitiv-select-item-font-weight--primitiv-select-item-color--primitiv-select-item-bg--primitiv-select-item-bg-highlighted--primitiv-select-item-spacing--primitiv-select-item-inset

Group label & separator

--primitiv-select-group-label-height--primitiv-select-group-label-padding-inline--primitiv-select-group-label-color--primitiv-select-group-label-font-family--primitiv-select-group-label-font-size--primitiv-select-group-label-font-weight--primitiv-select-group-label-line-height--primitiv-select-group-label-letter-spacing--primitiv-select-separator-spacing--primitiv-select-separator-color--primitiv-select-separator-thickness

Keyboard

KeyBehaviour
ArrowDown / ArrowUpMove focus to the next / previous option (wraps).
Home / EndFirst / last option.
Enter / SpaceSelect the focused option and close.
EscapeClose and return focus to the trigger.
printable characterTypeahead — focus the next option matching the prefix.

Data attributes

SelectTrigger

className: .primitiv-select

AttributeValueWhen
aria-expandedtrue | falsewhether the listbox is open
aria-invalidtrueinvalid
data-disabled""disabled — on the native `<select>`, which shares this class

SelectValue

className: .primitiv-select__value

AttributeValueWhen
data-placeholder""nothing selected

SelectItem

className: .primitiv-select__item

AttributeValueWhen
data-statechecked | uncheckedwhether this option is selected
data-disabled""the option is disabled

SelectItemIndicator

className: .primitiv-select__item-indicator

AttributeValueWhen
data-statechecked | uncheckedwhether 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

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.

Density
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";
<div data-density="comfortable">  <Select defaultValue="light">    <SelectTrigger>      <SelectValue placeholder="Choose a theme..." />      <SelectIcon><ChevronDown /></SelectIcon>    </SelectTrigger>    <SelectContent>      <SelectItem value="light">        <SelectItemIndicator><Check /></SelectItemIndicator>        <SelectItemLeading><Sun /></SelectItemLeading>        <SelectItemLabel>Light</SelectItemLabel>      </SelectItem>      {/* ... */}    </SelectContent>  </Select></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.

Density
import { Select, SelectItem } from "@/components/ui/select";
<div data-density="comfortable">  <Select native defaultValue="react" aria-label="Choose a framework">    <SelectItem value="react">React</SelectItem>    <SelectItem value="vue">Vue</SelectItem>    <SelectItem value="solid">Solid</SelectItem>  </Select></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.

Density
New York
São Paulo
Toronto
London
Berlin
Lisbon
import { Select, SelectContent, SelectGroup, SelectItem, SelectSeparator } from "@/components/ui/select";
<div data-density="comfortable">  <SelectContent>    <SelectGroup label="Americas">      <SelectItem value="new-york">...</SelectItem>    </SelectGroup>
    <SelectSeparator />
    <SelectGroup label="Europe">      <SelectItem value="london">...</SelectItem>    </SelectGroup>  </SelectContent></div>

Controlled

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.

Density
React
Vue
Solid
Selected: react
import { useState } from "react";import { Select, SelectTrigger, SelectValue, SelectIcon } from "@/components/ui/select";import { ChevronDown } from "@primitiv-ui/icons";
const [value, setValue] = useState("react");
<Select value={value} onValueChange={setValue}>  <SelectTrigger>    <SelectValue placeholder="Choose a framework..." />    <SelectIcon><ChevronDown /></SelectIcon>  </SelectTrigger>  {/* ... */}</Select>
// current value: "react"