Select

A Radix-style select primitive. A single value flows in via <Select.Root>, a single onValueChange flows out, and the compound exposes a Trigger / Value / Content / Item surface so consumers compose the parts directly in JSX. The Root owns open-state, the selected value, keyboard navigation, and an optional hidden mirror input that lets the value join native form submission.

Preview

Installation

npx shadcn@latest add https://craftbits.dev/r/select.json

Usage

Controlled — pair value with onValueChange so a parent owns the selection:

import { Select } from "@craft-bits/core";
 
const [plan, setPlan] = useState("pro");
 
<Select value={plan} onValueChange={setPlan}>
  <Select.Trigger>
    <Select.Value placeholder="Pick a plan" />
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="free">Free</Select.Item>
    <Select.Item value="pro">Pro</Select.Item>
    <Select.Item value="team">Team</Select.Item>
  </Select.Content>
</Select>

Uncontrolled — seed via defaultValue and let the Root own the state:

<Select defaultValue="pro">
  <Select.Trigger>
    <Select.Value placeholder="Pick a plan" />
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="free">Free</Select.Item>
    <Select.Item value="pro">Pro</Select.Item>
  </Select.Content>
</Select>

Grouped — split the listbox into labelled sections with a separator between:

<Select.Content>
  <Select.Group>
    <Select.Label>Pome</Select.Label>
    <Select.Item value="apple">Apple</Select.Item>
    <Select.Item value="pear">Pear</Select.Item>
  </Select.Group>
  <Select.Separator />
  <Select.Group>
    <Select.Label>Citrus</Select.Label>
    <Select.Item value="orange">Orange</Select.Item>
    <Select.Item value="lemon">Lemon</Select.Item>
  </Select.Group>
</Select.Content>

Understanding the component

  1. Compound API, Radix-compatible. <Select.Root> is also re-exported as the bare <Select> so the shorthand stays a single tag. Every part — Trigger, Value, Content, Item, Separator, Label, Group — is a forwardRef component that spreads ...props onto its root element.
  2. Trigger is a real combobox. <button role="combobox"> with aria-haspopup="listbox", aria-expanded, and aria-controls, so screen readers announce the open / closed state and can jump to the listbox. The caret glyph rotates 180° via data-state when the menu opens.
  3. Floating listbox, no portal. Content is absolute-positioned inside the Root so the Trigger and the panel share a stacking context. Click-outside and Escape dismiss the panel and return focus to the Trigger.
  4. Auto label registry. Each <Select.Item> registers its value -> label with the Root on mount, so <Select.Value> renders the matching label without manual wiring. Pass <Select.Value placeholder="..." /> to show a placeholder until the user picks something.
  5. Keyboard model. Tab into the Trigger; press Enter, Space, ArrowDown, or ArrowUp to open. Inside the listbox: ArrowDown / ArrowUp step between enabled items (wrapping), Home / End jump to the ends, Enter / Space select the focused item, Escape closes and returns focus to the Trigger.
  6. Token-only theming. Border, fill, accent, focus halo, and the row hover surface all read from --cb-* tokens.

Props

<Select> / <Select.Root>

PropTypeDefaultDescription
valuestringControlled selected value. Pair with onValueChange.
defaultValuestringUncontrolled initial value. Ignored when value is provided.
onValueChange(value: string) => voidFired with the next value on every user selection.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidFired whenever the open flag flips.
disabledbooleanfalseDisables the Trigger and prevents item selection.
requiredbooleanfalsePropagated to the hidden mirror input when name is set.
namestringAdds a hidden mirror <input type="hidden"> so the value joins native form submission.
classNamestringMerged onto the underlying <div>.

<Select.Trigger>

PropTypeDefaultDescription
size"sm" | "default""default"Trigger height — sm for dense rows, default for the standard 36 px row.
classNamestringMerged onto the underlying <button role="combobox">.

<Select.Value>

PropTypeDefaultDescription
placeholderReactNodeShown when no value is selected yet.
classNamestringMerged onto the underlying <span>.

<Select.Item>

PropTypeDefaultDescription
valuestringValue this row represents. Required.
disabledbooleanfalseDisables only this row.
classNamestringMerged onto the underlying <button role="option">.

Accessibility

  • The Root passes data-state="open" | "closed" and data-disabled to its <div> so styling and assistive tech see the state at the wrapper level.
  • The Trigger renders as <button role="combobox"> with aria-haspopup="listbox", aria-expanded, and aria-controls wired to the Content's id.
  • The Content renders as <div role="listbox"> with aria-labelledby pointing back at the Trigger, so the listbox inherits the Trigger's accessible name.
  • Each Item is <button role="option"> with aria-selected and data-state="checked" | "unchecked". Disabled items lose pointer reachability and are skipped by arrow-key navigation.
  • Keyboard: Enter / Space / ArrowDown / ArrowUp on the Trigger open the menu. Inside the listbox, ArrowDown / ArrowUp step with wrap-around; Home / End jump to the ends; Enter / Space select; Escape closes and returns focus to the Trigger.
  • When the menu opens, focus moves into the currently selected item (or the first item if nothing is selected) so keyboard users land on the right row.

Credits

  • Extracted from: algoflashcards (src/platform/ui/select.tsx). The source wrapped radix-ui's Select.* primitives with Phosphor caret + check icons. craft-bits drops both external dependencies — the trigger is a plain <button role="combobox">, the listbox is a motion.div with role="listbox", the glyphs are inlined SVGs — and surfaces an auto label-registry so <Select.Value> renders the matching item label without the consumer mapping value -> label by hand.