Pho 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="[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

Props

Select.Root

Select.Trigger

Select.Value

Select.Item

Select.Positioner

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:

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