Select
Pick one option from a list that opens below the trigger — the control other
design systems call a Picker. Built on Base UI Select. For a short,
always-visible set use RadioGroup; to filter a long list by typing use
Combobox.
Import
import { Select } from "@photon-ai/pho-ui/components/select";
Basic
Pass items to Select.Root, render a Select.Item per option, and show the
current choice with Select.Value. With items set, Select.Value renders the
selected item’s label rather than its raw value.
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
<Select.Root items={frameworks} defaultValue="astro">
<Select.Trigger>
<Select.Value placeholder="Select a framework" />
<Select.Icon>
<IconChevronDown />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List>
{frameworks.map((f) => (
<Select.Item key={f.value} value={f.value}>
<Select.ItemIndicator>
<IconCheck />
</Select.ItemIndicator>
<Select.ItemText>{f.label}</Select.ItemText>
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>;
Grouped options
Wrap each set of items in a Select.Group with a Select.GroupLabel. The label
is a heading for its options, not a selectable row, so keyboard navigation skips
over it.
<Select.List>
<Select.Group>
<Select.GroupLabel>Sans-serif</Select.GroupLabel>
<Select.Item value="geist">
<Select.ItemText>Geist</Select.ItemText>
</Select.Item>
{/* … */}
</Select.Group>
<Select.Group>
<Select.GroupLabel>Serif</Select.GroupLabel>
{/* … */}
</Select.Group>
</Select.List>
Multiple
Set multiple and the value becomes an array; each pick toggles rather than
replaces, and the popup stays open. Select.Value joins the selected labels.
Select.ItemIndicator switches to an always-visible checkbox per option, so
multi selection reads as a checklist instead of a lone checkmark.
<Select.Root items={frameworks} multiple defaultValue={["astro", "remix"]}>
<Select.Trigger>
<Select.Value placeholder="Select frameworks" />
<Select.Icon>
<IconChevronDown />
</Select.Icon>
</Select.Trigger>
{/* …same popup as Basic — ItemIndicator draws the checkbox in multiple mode… */}
</Select.Root>
Inline
variant="inline" on Select.Trigger drops the field chrome — ring,
background, padding, min-width — so the trigger can ride a host surface that
already draws one, like an InputGroup.Addon or a toolbar. The host carries
the focus and open treatment (Input Group’s shared ring covers both), text
inherits the host tone, and the press pulse is shallower and quicker than the
default field’s, so the host surface barely stirs. The default variant is
unchanged.
<InputGroup.Root size="sm">
<InputGroup.Input type="email" placeholder="[email protected]" />
<InputGroup.Separator />
<InputGroup.Addon>
<Select.Root items={roles} defaultValue="member">
<Select.Trigger
aria-label="Invitation role"
variant="inline"
className="min-w-24 text-sm"
>
<Select.Value />
<Select.Icon>
<IconChevronDown />
</Select.Icon>
</Select.Trigger>
{/* …same popup as Basic… */}
</Select.Root>
</InputGroup.Addon>
</InputGroup.Root>
Disabled
Set disabled on Select.Root to freeze the current value and take the trigger
out of the tab order; the control still shows its selection but won’t open. Use
readOnly instead when the popup should still open for inspection but the value
can’t change.
<Select.Root items={frameworks} defaultValue="astro" disabled>
{/* …same trigger and popup as Basic… */}
</Select.Root>
Anatomy
- Select.Root — owns
items,value/defaultValue,multiple,disabled - Select.Trigger → Value + Icon — the closed control, its label, and the caret
- Select.Portal → Positioner → Popup → List — the floating option list
- Select.Group → GroupLabel — an optional labelled cluster of items
- Select.Item → ItemIndicator + ItemText — one option; the mark column is reserved, so labels stay aligned whether or not an item is selected (single: brand check · multiple: checkbox)
Props
Select.Root
- items —
{ label, value }[] | Record— the option data; drives the labelSelect.Valueshows - value / defaultValue —
Value | Value[] | null— controlled / uncontrolled selection (an array whenmultiple) - onValueChange —
(value, eventDetails) => void— fires when the selection changes - multiple —
boolean(defaultfalse) — allow selecting several options - disabled —
boolean(defaultfalse) — ignore all user interaction - readOnly —
boolean(defaultfalse) — keep the value fixed but still openable - required —
boolean(defaultfalse) — a value must be chosen before form submit - name —
string— form field name for the hidden input - open / defaultOpen / onOpenChange —
boolean/(open, details) => void— control the popup’s open state - modal —
boolean(defaulttrue) — lock page scroll and outside interaction while open
Select.Trigger
- variant —
default·inline(defaultdefault) —inlinedrops the field chrome to ride a host surface (Input Group addon, toolbar), with a shallower, quicker press pulse - disabled —
boolean— disable just this control
Select.Value
- placeholder —
string— shown when nothing is selected - children —
(value) => ReactNode— format the label yourself
Select.Item
- value —
Value(defaultnull) — the value this option selects - label —
string— text used for type-ahead matching (defaults to the item’s text) - disabled —
boolean(defaultfalse) — skip this option
Select.Positioner
- sideOffset —
number(default6) — gap between trigger and popup - side / align — Base UI anchor positioning (defaults
bottom/start) - alignItemWithTrigger —
boolean(defaultfalse) — Base UI’s macOS-style overlap, where the popup covers the trigger to line the selected item up with the value text. Pho turns it off: the list always drops below the field, which keeps the popup where you expect it
Accessibility
The trigger is a native <button> with aria-haspopup="listbox" and
aria-expanded; the popup is a role="listbox" and each Select.Item is a
role="option" carrying aria-selected. Give the trigger an accessible name —
associate a <label> (or a Field), or set aria-label when the selected value
alone isn’t descriptive.
Keyboard, with the trigger focused:
- Enter, Space, Arrow Up / Down — open the popup
- Arrow Up / Down — move the highlight between options
- Home / End — jump to the first / last option
- Type a few letters — match an option by its text (or its
label) - Enter — select the highlighted option and close
- Escape — close without changing the value
Opening moves focus into the popup; closing returns it to the trigger, which
shows a ring on focus-visible and keeps the brand ring while open
(data-popup-open). Disabled options (data-disabled) are skipped by keyboard
navigation, and the current selection is marked both visually with
Select.ItemIndicator and semantically with aria-selected.
Best practices
- Use a select past ~6 options or when vertical space is tight; below that, a
RadioGroupshows every choice at once and is faster to scan. - Give
Select.Valueaplaceholderand, ideally, a sensibledefaultValue. - Group long lists with
Select.Group+Select.GroupLabelso related options cluster and scan quickly. - When users will want to search, switch to
Combobox— a long unfiltered select is tedious.