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>;
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 selectedvalue, filtering, andmultiple; renders no element of its own - Combobox.InputGroup → Input / Trigger / Clear — the editable single-select control;
Triggertoggles the popup,Clearempties the input - Combobox.Chips → Value / Chip / ChipRemove — the multi-select control;
Value’s render function draws oneChipper selected value - Combobox.Portal → Positioner → Popup → List — the floating results surface;
Emptyrenders 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(defaultfalse) — 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 (defaultfalse) - onOpenChange —
(open, details) => void— fired when the popup opens or closes - filter —
((item, query, itemToString?) => boolean) | null— custom match logic;nulldisables built-in filtering - limit —
number(default-1) — cap the number of items shown;-1means no cap - autoHighlight —
boolean(defaultfalse) — highlight the first match automatically while filtering - openOnInputClick —
boolean(defaulttrue) — open the popup when the input is clicked - modal —
boolean(defaultfalse) — lock page scroll and outside interaction while open - disabled —
boolean(defaultfalse) — ignore all interaction - readOnly —
boolean(defaultfalse) — allow typing to filter but not choosing a different value - required —
boolean(defaultfalse) — 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 toObject.is)
Accessibility
- Roles — the
Inputis the ARIAcombobox; thePopuplist is alistboxand eachItemanoptionwitharia-selected. Give theInputan accessible name with a<label>oraria-label, and label theTrigger(e.g.aria-label="Open") and eachChipRemovesince they’re icon-only. - Focus — focus stays in the input the whole time. Arrow-key navigation
moves a virtual highlight tracked with
aria-activedescendantrather 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 exposesdata-starting-style/data-ending-stylefor its open/close transition.disabledandreadOnlypropagate to the underlying controls.
Keyboard:
↓/↑— move the highlight through the filtered itemsEnter— commit the highlighted itemEsc— close the popupBackspace— 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.Emptyso 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
Autocompleteinstead. - Give the
Inputa real label and every icon-only control (Trigger,ChipRemove) anaria-label— the name should say what it does, not what it looks like.