Combobox
Filterable select bound to an option list — single or multi-select with chips, groups, async loading, creatable rows, and the full field anatomy.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
ComboboxRoot,
ComboboxInputGroup,
ComboboxInput,
ComboboxTrigger,
ComboboxClear,
ComboboxPopup,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@tessinaui/ui";<ComboboxRoot items={fruits} label="Fruit" value={value} onValueChange={setValue}>
<ComboboxInputGroup>
<ComboboxInput placeholder="Search…" />
<ComboboxClear />
<ComboboxTrigger />
</ComboboxInputGroup>
<ComboboxPopup>
<ComboboxList>
{fruits.map((f) => (
<ComboboxItem key={f} value={f}>{f}</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty />
</ComboboxPopup>
</ComboboxRoot>Pass the option collection through items — that is what the built-in filter
narrows as the user types. Static children without items render fine but do
not filter themselves.
When to use — and when not to
- Combobox — pick from a list the user would search rather than scan: long lists, remote data, tag/multi entry with chips. The filter input is the core of the control.
- A short, known, fixed list with no filtering is a Select. If the trigger
should show the chosen value as a button (not an editable input), that is
also Select — its
multiplealready does "2 selected" summary triggers. - A ⌘K overlay of commands and navigation is Command — same wiring, different pattern (centered dialog, shortcut hints, action rows).
- A page-level search box with history, scopes and a submit action is
Search. The
combobox-recentsexample shows where the boundary runs. - Free-form token entry where arbitrary text becomes chips (emails, keywords) with paste/comma splitting is Tokenizer; Combobox chips always come from the option list (plus an explicit create row).
- A compact unit/currency selector attached to an input is FieldDropdown.
- Rows that act (delete, invite, navigate) belong in DropdownMenu.
Examples
Default
A single-select combobox with a label, clear button, and filterable list.
Multiple
Multi-select mode renders chosen values as removable chips inside the input, and option rows switch to checkbox visuals.
Grouped
Options organized under group labels, with separators between sections.
Sizes
Five height scales — xs to xl — on the field family's shared type ladder
(12/14/14/16/16, 16px floor on phones).
Validation
errorMessage replaces the supporting text, implies the error intent, sets
aria-invalid, and is announced politely. required stars the label;
optional suffixes it.
Ghost variant
Borderless fields for toolbars and property rows — with visuallyHiddenLabel
keeping the accessible name.
On-color
Inside a colored Surface the tone resolves automatically: well, chips, rings and meta text all read the surface contract.
States
Disabled, read-only (focusable, value locked), and disabled options inside an enabled list.
Trailing meta
trailingText puts muted meta on the row end — dial codes, counts, prices —
outside the accessible name. Leading flags via leadingIcon.
Match highlight
ComboboxHighlight bolds the matched substring while the accessible name
stays whole.
Checkbox items
Multi-select filter panels: select-all / clear-all in the pinned footer with a live "n/N" count.
Max selection
Cap the selection in onValueChange — removals always pass, the helper counts
what's left, and full lists disable the rest.
Creatable
Unknown input gets a "Create ‹query›" row that promotes the text to a real, selected option.
Async loading
Debounced remote search: min-characters hint, ComboboxStatus while loading,
an error state with retry, and a result count in the footer.
Recents
openOnInputClick surfaces recent and trending groups before the user types.
Empty state with action
ComboboxEmpty accepts arbitrary children — pair the "no results" message
with a way forward.
Footer action
A pinned row under the list for manage/config links — it never scrolls away.
Panel mode (search inside the popup)
A button-shaped trigger with the search input inside the popup — the branch-picker pattern.
Scrollable
Long lists cap their height and scroll inside the popup.
Loading
Skeletons that mirror the real anatomy — including the chip row in multiple mode.
API Reference
ComboboxRoot props
All Base UI Combobox.Root props are forwarded (items, value,
onValueChange, inputValue, onInputValueChange, filter, filteredItems,
multiple, limit, locale, autoHighlight, openOnInputClick,
virtualized, grid, inline, actionsRef, …). House props:
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Field height; text reads the family ladder |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border, ring and supporting color |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Radius cascade — popup and items follow |
variant | "outline" | "ghost" | "outline" | Bordered well vs borderless toolbar field |
tone | "default" | "on-color" | from Surface | On colored plates; resolves from Surface context |
labelPlacement | "top" | "inside" | "start" | "top" | Logical label layout |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
label | string | — | Label text, associated via htmlFor |
required | boolean | — | Star on the label + required on the input |
optional | boolean | — | "(optional)" label suffix; ignored with required |
infoText | string | — | Info hint at the label-row end, in the describedby chain |
supportingText | string | — | Helper below the field (polite region) |
errorMessage | string | — | Replaces the helper, implies error intent, sets aria-invalid |
visuallyHiddenLabel | boolean | — | Keep the name for AT, hide the label visually |
popupPadding | "narrow" | "default" | "wide" | "default" | Popup inner padding |
dir | "ltr" | "rtl" | — | Logical direction (wraps in a DirectionProvider when set) |
labelPosition | — | — | Deprecated — use labelPlacement |
width | — | — | Deprecated — use popupPadding |
wrapperClassName | — | — | Deprecated — use className |
Without a label, give the input its own aria-label — the input must have a
name.
ComboboxItem props
| Prop | Type | Description |
|---|---|---|
value | any | The value this item represents |
disabled | boolean | Prevents selection |
leadingIcon | ReactNode | Icon before the label |
leadingAvatar | ReactNode | Avatar before the label |
description | string | Second line — outside the name, wired as the description |
trailingText | ReactNode | Muted end-of-row meta, outside the name |
In multiple mode every item renders a checkbox visual; single-select items
render the trailing checkmark.
ComboboxHighlight props
| Prop | Type | Description |
|---|---|---|
text | string | The full option text |
query | string | Substring to bold (case-insensitive, every occurrence) |
ComboboxLeadingIcon props
| Prop | Type | Description |
|---|---|---|
onClick | () => void | Renders a real focusable <button> — name it with aria-label. Omit for a decorative slot |
ComboboxPopup props
| Prop | Type | Description |
|---|---|---|
positionerProps | Positioner props | Side, align, offsets, and style — the popup defaults to the anchor's width via --anchor-width; override it here |
ComboboxList
The listbox and the scrollport are the same element — Base UI puts
role="listbox" and the id aria-controls resolves against on this node — so
the list keeps a native scrollport wearing the house scrollbarSkin rather
than composing the ScrollArea component. ScrollArea cannot re-measure a list
whose content height changes from filtering; see
When you can't use it: scrollbarSkin.
It caps at min(18rem, 100dvh - 6rem) and scrolls.
ComboboxFooter
A pinned row under ComboboxList — manage links, bulk actions, result counts.
It bleeds to the popup edge and never scrolls with the list.
ComboboxFilterTabs / ComboboxFilterTab
| Prop | Type | Description |
|---|---|---|
value / onValueChange | string / (v) => void | Controlled active filter |
count | number | Count badge on a tab |
Plain aria-pressed toggles in a labelled group — see the accessibility note.
ComboboxSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
size / rounded | as Root | md / full | Geometry from the same maps |
showLabel | boolean | false | Label row |
labelPlacement | "top" | "inside" | "start" | "top" | Mirrors the real layout |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
showSupportingText | boolean | false | Helper row |
showLeadingIcon | boolean | false | Leading icon circle |
showChips | boolean | false | Chip pills for multiple mode |
Accessibility
The input itself carries role="combobox" with aria-expanded,
aria-controls and aria-activedescendant; DOM focus never leaves the input
while arrowing through options. The visible (or sr-only) label names the input
via htmlFor; supportingText / errorMessage / infoText join
aria-describedby, the meta row is a polite live region, and errorMessage
sets aria-invalid. Option rows announce their label alone — description
becomes the option's description, trailingText stays out of both.
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | Into / out of the input (the chevron button is not a tab stop) |
↓ / ↑ | Open the popup; move the highlight (wraps) |
Enter | Accept the highlighted option and close |
Esc | Close the popup |
Home / End | Move the text caret, not the list highlight |
| Printable keys | Type and filter — Space types a space, it never selects |
← / → on an empty multi input | Walk the chips |
Backspace on an empty multi input | Remove the last chip |
AT matrix
| AT | Status |
|---|---|
| VoiceOver (macOS) | Verified — name, role, expanded state, option announcements, polite error swap |
| NVDA | Untested — cannot run on this hardware |
| JAWS | Untested — cannot run on this hardware |
Known limitations
- Filter tabs are a pointer/AT refinement. The popup keeps DOM focus on
the input, so the tab buttons are not keyboard-reachable while the popup is
open. They are deliberately
aria-pressedtoggles, not atablist. Keep a keyboard route to the same filtering — typing, or controls outside the popup. - Interactive rows inside the footer (
ComboboxFooter) are reachable by pointer and AT cursor, not byTabwhile the popup is open.
Migrating
| Before | After |
|---|---|
labelPosition="outside-top" | labelPlacement="top" |
labelPosition="inside" | labelPlacement="inside" — now renders a real floating label |
labelPosition="outside-left" | labelPlacement="start" |
width="narrow | wide" | popupPadding="narrow | wide" |
wrapperClassName | className |
Deprecated props keep working for one minor. Visual changes in this release:
the focus ring keeps the border underneath (field model), input/item text
adopts the shared field ladder (sm 12→14, lg 14→16 on desktop), rounded
gains xl, and the pill popup cap is now the §2 panel radius.