Photon Design System

Combobox

A text input that filters a list as you type and commits to a value from it. Built on Base UI Combobox. For free-form text with suggestions use Autocomplete; for a short fixed set use Select.

Import

import { Combobox } from "@photon-ai/pho-ui/components/combobox";

Basic

Pass items to Combobox.Root. Combobox.List takes a render function that maps each filtered item to a Combobox.Item; Combobox.Empty shows when nothing matches. When items are { value, label } objects, the popup filters on label and the input shows it without any extra wiring.

import { IconCheck, IconSelector } from "@tabler/icons-react";

<Combobox.Root items={fruits}>
  <Combobox.InputGroup>
    <Combobox.Input placeholder="Search fruits…" />
    <Combobox.Trigger aria-label="Open">
      <IconSelector />
    </Combobox.Trigger>
  </Combobox.InputGroup>
  <Combobox.Portal>
    <Combobox.Positioner>
      <Combobox.Popup>
        <Combobox.Empty>No fruits found.</Combobox.Empty>
        <Combobox.List>
          {(item) => (
            <Combobox.Item key={item.value} value={item}>
              <Combobox.ItemIndicator>
                <IconCheck />
              </Combobox.ItemIndicator>
              {item.label}
            </Combobox.Item>
          )}
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>;

Searchable select

When the control should read as a Select (a value in a field, a list that opens under it) but the list is long enough to want a search, Combobox.Trigger variant="field" is the whole control: Select’s chrome around a Combobox.Value (with its placeholder) and a Combobox.Icon. The Combobox.Input then moves inside the popup as variant="popup", a borderless search line over the list; opening the field focuses it, typing filters, and a pick lands in the field and closes the popup. A form can seat it beside a Select and no one tells them apart at rest.

<Combobox.Root items={areaCodes}>
  <Combobox.Trigger variant="field" aria-label="Area code" className="w-64">
    <Combobox.Value placeholder="Any area code" />
    <Combobox.Icon>
      <IconSelector />
    </Combobox.Icon>
  </Combobox.Trigger>
  <Combobox.Portal>
    <Combobox.Positioner>
      <Combobox.Popup>
        <Combobox.Input
          variant="popup"
          aria-label="Search area codes"
          placeholder="Search by code or city"
        />
        <Combobox.Empty>No area code matches.</Combobox.Empty>
        <Combobox.List>{(item) => <Combobox.Item …>{item.label}</Combobox.Item>}</Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

Multi-select

Set multiple on the root and swap Combobox.InputGroup for Combobox.Chips. Render Combobox.Value with a function that maps each selected value to a Combobox.Chip (with a Combobox.ChipRemove), then place the Combobox.Input after it so typing continues inline. The value is now an array; Backspace on an empty input removes the last chip.

import { IconCheck, IconX } from "@tabler/icons-react";

<Combobox.Root items={fruits} multiple>
  <Combobox.Chips>
    <Combobox.Value>
      {(selected) =>
        selected.map((item) => (
          <Combobox.Chip key={item.value} aria-label={item.label}>
            {item.label}
            <Combobox.ChipRemove aria-label={`Remove ${item.label}`}>
              <IconX />
            </Combobox.ChipRemove>
          </Combobox.Chip>
        ))
      }
    </Combobox.Value>
    <Combobox.Input placeholder="Add fruits…" />
  </Combobox.Chips>
  <Combobox.Portal>{/* …same popup as Basic… */}</Combobox.Portal>
</Combobox.Root>;

Grouped options

Pass an array of { items } groups to render labelled sections. The Combobox.List render function then receives each group instead of each item: wrap the group’s items in a Combobox.Group, label it with Combobox.GroupLabel, and map them to Combobox.Items through a Combobox.Collection. The built-in filter still matches across every group at once.

import { IconCheck, IconSelector } from "@tabler/icons-react";

<Combobox.Root items={produce}>
  {/* …same input group as Basic… */}
  <Combobox.Portal>
    <Combobox.Positioner>
      <Combobox.Popup>
        <Combobox.Empty>No produce found.</Combobox.Empty>
        <Combobox.List>
          {(group) => (
            <Combobox.Group key={group.value} items={group.items}>
              <Combobox.GroupLabel>{group.value}</Combobox.GroupLabel>
              <Combobox.Collection>
                {(item) => (
                  <Combobox.Item key={item.value} value={item}>
                    <Combobox.ItemIndicator>
                      <IconCheck />
                    </Combobox.ItemIndicator>
                    {item.label}
                  </Combobox.Item>
                )}
              </Combobox.Collection>
            </Combobox.Group>
          )}
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>;

Controlled

Everything is uncontrolled by default. To drive the committed value from your own state, pass value + onValueChange; for the initial value only, use defaultValue. The typed text is separate state — control it with inputValue + onInputValueChange (or defaultInputValue), and control open/closed with open

  • onOpenChange.
const [value, setValue] = useState<Fruit | null>(null);

<Combobox.Root items={fruits} value={value} onValueChange={setValue}>
  {/* … */}
</Combobox.Root>;

Anatomy

  • Combobox.Root — owns items, the selected value, filtering, and multiple; renders no element of its own
  • Combobox.InputGroup → Input / Trigger / Clear — the editable single-select control; Trigger toggles the popup, Clear empties the input
  • Combobox.Trigger variant=“field” → Value / Icon — the select-like control for a searchable select; the Input (variant="popup") then sits at the top of the Popup
  • Combobox.Chips → Value / Chip / ChipRemove — the multi-select control; Value’s render function draws one Chip per selected value
  • Combobox.Portal → Positioner → Popup → List — the floating results surface; Empty renders inside when nothing matches
  • Combobox.Item → ItemIndicator — one option and its selected check
  • Combobox.Group → GroupLabel / Collection — an optional labelled section of items

Props

Props go on Combobox.Root unless noted; every part also forwards its Base UI props plus className.

  • items — readonly T[] — the options to filter and render (also accepts an array of { items } groups)
  • multiple — boolean (default false) — allow selecting more than one value; the value becomes an array
  • value — T | T[] | null — the committed value(s), controlled
  • defaultValue — T | T[] | null — the initial committed value, uncontrolled
  • onValueChange — (value, details) => void — fired when the selection changes
  • inputValue — string — the typed text, controlled
  • defaultInputValue — string — the initial typed text, uncontrolled
  • onInputValueChange — (value, details) => void — fired as the user types
  • open / defaultOpen — boolean — popup open state, controlled / initial (default false)
  • onOpenChange — (open, details) => void — fired when the popup opens or closes
  • filter — ((item, query, itemToString?) => boolean) | null — custom match logic; null disables built-in filtering
  • limit — number (default -1) — cap the number of items shown; -1 means no cap
  • autoHighlight — boolean (default false) — highlight the first match automatically while filtering
  • openOnInputClick — boolean (default true) — open the popup when the input is clicked
  • modal — boolean (default false) — lock page scroll and outside interaction while open
  • disabled — boolean (default false) — ignore all interaction
  • readOnly — boolean (default false) — allow typing to filter but not choosing a different value
  • required — boolean (default false) — the field must have a value before its form submits
  • name — string — form field name for the hidden input
  • itemToStringLabel / itemToStringValue — (item) => string — derive the display string / submitted value from object items (inferred for { value, label })
  • isItemEqualToValue — (item, value) => boolean — custom equality for object items (defaults to Object.is)

On the parts:

  • Combobox.Trigger variant — icon / field (default icon) — the small button beside an input, or the whole select-like field around a Value and an Icon
  • Combobox.Input variant — field / popup (default field) — the boxed control, or the search line inside the popup under a field trigger
  • Combobox.Value placeholder — what a field trigger shows with nothing chosen (the trigger carries data-placeholder and mutes its ink)

Accessibility

  • Roles — the Input is the ARIA combobox; the Popup list is a listbox and each Item an option with aria-selected. Give the Input an accessible name with a <label> or aria-label, and label the Trigger (e.g. aria-label="Open") and each ChipRemove since they’re icon-only.
  • Focus — focus stays in the input the whole time. Arrow-key navigation moves a virtual highlight tracked with aria-activedescendant rather than moving DOM focus, so typing never breaks. loopFocus (default on) wraps from the last item back to the input.
  • State — the highlighted option carries data-highlighted; the popup exposes data-starting-style / data-ending-style for its open/close transition. disabled and readOnly propagate to the underlying controls.

Keyboard:

  • ↓ / ↑ — move the highlight through the filtered items
  • Enter — commit the highlighted item
  • Esc — close the popup
  • Backspace — on an empty multi-select input, remove the last chip
  • typing — filters the list and opens the popup

Best practices

  • Reach for a combobox when the list is long enough that scrolling is slower than typing — countries, repos, users.
  • Always provide a Combobox.Empty so a no-match search isn’t a blank void.
  • The value is constrained to the list; if users must enter values that aren’t there, use Autocomplete instead.
  • Give the Input a real label and every icon-only control (Trigger, ChipRemove) an aria-label — the name should say what it does, not what it looks like.