Photon Design System

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="teammate@company.com" />
  <InputGroup.Separator />
  <InputGroup.Addon>
    <Select.Root items={roles} defaultValue="member">
      <Select.Trigger
        aria-label="Invitation role"
        variant="inline"
        className="min-w-24 text-base"
      >
        <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 label Select.Value shows
  • value / defaultValue — Value | Value[] | null — controlled / uncontrolled selection (an array when multiple)
  • onValueChange — (value, eventDetails) => void — fires when the selection changes
  • multiple — boolean (default false) — allow selecting several options
  • disabled — boolean (default false) — ignore all user interaction
  • readOnly — boolean (default false) — keep the value fixed but still openable
  • required — boolean (default false) — 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 (default true) — lock page scroll and outside interaction while open

Select.Trigger

  • variant — default · inline (default default) — inline drops 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 (default null) — the value this option selects
  • label — string — text used for type-ahead matching (defaults to the item’s text)
  • disabled — boolean (default false) — skip this option

Select.Positioner

  • sideOffset — number (default 6) — gap between trigger and popup
  • side / align — Base UI anchor positioning (defaults bottom / start)
  • alignItemWithTrigger — boolean (default false) — Base UI’s macOS-style overlap, where the popup covers the trigger to line the selected item up with the value text. Photon 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 RadioGroup shows every choice at once and is faster to scan.
  • Give Select.Value a placeholder and, ideally, a sensible defaultValue.
  • Group long lists with Select.Group + Select.GroupLabel so related options cluster and scan quickly.
  • When users will want to search, switch to Combobox — a long unfiltered select is tedious.