Autocomplete
A free-text input that offers matching suggestions as you type. Built on Base UI
Autocomplete. Unlike Combobox, the value isn’t constrained to the list — the
user can submit text that matches no suggestion.
Import
import { Autocomplete } from "@photon-ai/pho-ui/components/autocomplete";
Basic
Pass items to Autocomplete.Root; Autocomplete.List maps each suggestion to
an Autocomplete.Item. The list filters as you type, and the typed text is the
value whether or not it matches — Autocomplete.Empty renders when nothing does.
<Autocomplete.Root items={labels}>
<Autocomplete.InputGroup>
<Autocomplete.Input placeholder="Add a label…" />
</Autocomplete.InputGroup>
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.Empty>
No matches — press Enter to add.
</Autocomplete.Empty>
<Autocomplete.List>
{(label) => (
<Autocomplete.Item key={label} value={label}>
{label}
</Autocomplete.Item>
)}
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>
Clear
Add Autocomplete.Clear inside the InputGroup for a one-click reset. It’s an
icon-only button, so it needs an aria-label; it mounts only while the field
has a value and unmounts when empty. Pad the input’s right edge so text clears
the button.
import { IconX } from "@tabler/icons-react";
<Autocomplete.InputGroup>
<Autocomplete.Input className="pr-9" placeholder="Add a label…" />
<Autocomplete.Clear aria-label="Clear">
<IconX />
</Autocomplete.Clear>
</Autocomplete.InputGroup>;
Grouped options
Pass an array of { items } groups instead of a flat list to bucket suggestions
under headings. Each List child is a group — render an Autocomplete.Group
with its Autocomplete.GroupLabel, then an Autocomplete.Collection that maps
the group’s own items.
const groups = [
{ value: "Environments", items: ["production", "staging", "preview"] },
{ value: "Regions", items: ["us-east", "us-west", "eu-central"] },
];
<Autocomplete.Root items={groups}>
{/* …InputGroup / Portal / Positioner / Popup… */}
<Autocomplete.List>
{(group) => (
<Autocomplete.Group key={group.value} items={group.items}>
<Autocomplete.GroupLabel>{group.value}</Autocomplete.GroupLabel>
<Autocomplete.Collection>
{(item) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.Collection>
</Autocomplete.Group>
)}
</Autocomplete.List>
</Autocomplete.Root>;
Controlled
The value is the input’s text — control it with value + onValueChange to read
what the user typed (matching suggestion or not), sync it to a URL query, or seed
it from elsewhere. Use defaultValue for the initial text only.
const [value, setValue] = useState("");
<Autocomplete.Root items={labels} value={value} onValueChange={setValue}>
{/* …InputGroup / Portal / Positioner / Popup… */}
</Autocomplete.Root>;
Anatomy
- Autocomplete.Root — owns
items, the free-text value, and open state - Autocomplete.InputGroup — wraps the control; positions
InputandClear - Autocomplete.Input — the editable
comboboxfield - Autocomplete.Clear — icon button that resets the value (mounts when non-empty)
- Autocomplete.Portal → Positioner → Popup — the floating suggestion layer
- Autocomplete.List — the
listbox; maps items (or groups) to children - Autocomplete.Item — one selectable
option - Autocomplete.Empty — the no-match message (a polite live region)
- Autocomplete.Group / GroupLabel / Collection — heading + items for grouped data
Props
Set on Autocomplete.Root; the wrapper forwards the rest of Base UI’s
Autocomplete.Root props.
- items —
readonly T[]orreadonly { items: T[] }[]— the suggestion data, flat or grouped - mode —
list·both·inline·none(defaultlist) — filtering + inline-completion behavior - value / defaultValue —
string— the input’s text value, controlled / uncontrolled - onValueChange —
(value: string, details) => void— fired as the text changes - filter —
((itemValue, query) => boolean) | null— custom matcher, ornullto disable filtering - autoHighlight —
boolean | "always"(defaultfalse) — highlight the first match automatically - openOnInputClick —
boolean(defaultfalse) — open the popup when the input is clicked - open / defaultOpen —
boolean— control popup visibility; onOpenChange —(open, details) => void - disabled / readOnly / required —
boolean(defaultfalse) - name —
string— field name for form submission
Parts (Input, Clear, Popup, Item, …) each take a className and their
native element attributes.
Accessibility
- Semantics —
Inputis arole="combobox";Listis itsrole="listbox"and eachItemarole="option". Base UI wiresaria-expanded,aria-controls, andaria-activedescendantbetween them. Give the input a real accessible name with a<label>oraria-label— aplaceholderalone doesn’t name the field.Autocomplete.Clearis icon-only and needs its ownaria-label. - Live region —
Autocomplete.Emptyrendersrole="status"witharia-live="polite", so its no-match text is announced when suggestions run out. Keep that message useful (No matches — press Enter to add). - Focus — virtual: DOM focus stays on the input while the active option is
tracked via
aria-activedescendant, so typing never leaves the field.
Keyboard:
- Type — filter the list
- Down / Up — move the highlight through options (virtual focus)
- Enter — accept the highlighted suggestion; with nothing highlighted, your typed text stands
- Escape — close the popup
- Home / End — move the text cursor to the start / end of the input
Best practices
- Use autocomplete when arbitrary text is valid but suggestions speed things up
— labels, tags, search queries. If the value must come from the list, use
Combobox. - Make
Autocomplete.Emptyactionable (“press Enter to add”) so a no-match state still moves the user forward. - Keep suggestions ranked by relevance; with
autoHighlighton, the first item is what Enter selects, so rank matters. - Label the input properly — a
placeholderis a hint, not an accessible name.