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.jsonUsage
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
- 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 aforwardRefcomponent that spreads...propsonto its root element. - Trigger is a real combobox.
<button role="combobox">witharia-haspopup="listbox",aria-expanded, andaria-controls, so screen readers announce the open / closed state and can jump to the listbox. The caret glyph rotates 180° viadata-statewhen the menu opens. - 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.
- Auto label registry. Each
<Select.Item>registers itsvalue -> labelwith 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. - 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.
- Token-only theming. Border, fill, accent, focus halo, and the row hover surface all read from
--cb-*tokens.
Props
<Select> / <Select.Root>
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled selected value. Pair with onValueChange. |
defaultValue | string | — | Uncontrolled initial value. Ignored when value is provided. |
onValueChange | (value: string) => void | — | Fired with the next value on every user selection. |
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fired whenever the open flag flips. |
disabled | boolean | false | Disables the Trigger and prevents item selection. |
required | boolean | false | Propagated to the hidden mirror input when name is set. |
name | string | — | Adds a hidden mirror <input type="hidden"> so the value joins native form submission. |
className | string | — | Merged onto the underlying <div>. |
<Select.Trigger>
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "default" | Trigger height — sm for dense rows, default for the standard 36 px row. |
className | string | — | Merged onto the underlying <button role="combobox">. |
<Select.Value>
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | ReactNode | — | Shown when no value is selected yet. |
className | string | — | Merged onto the underlying <span>. |
<Select.Item>
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Value this row represents. Required. |
disabled | boolean | false | Disables only this row. |
className | string | — | Merged onto the underlying <button role="option">. |
Accessibility
- The Root passes
data-state="open" | "closed"anddata-disabledto its<div>so styling and assistive tech see the state at the wrapper level. - The Trigger renders as
<button role="combobox">witharia-haspopup="listbox",aria-expanded, andaria-controlswired to the Content's id. - The Content renders as
<div role="listbox">witharia-labelledbypointing back at the Trigger, so the listbox inherits the Trigger's accessible name. - Each Item is
<button role="option">witharia-selectedanddata-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 wrappedradix-ui'sSelect.*primitives with Phosphor caret + check icons. craft-bits drops both external dependencies — the trigger is a plain<button role="combobox">, the listbox is amotion.divwithrole="listbox", the glyphs are inlined SVGs — and surfaces an auto label-registry so<Select.Value>renders the matching item label without the consumer mappingvalue -> labelby hand.