Combobox
Filterable select bound to an option list — single or multi-select with chips, groups, async loading, creatable rows, and the full field anatomy.
pnpm add @tessinaui/uiWhen to use
- A long list people would search rather than scan: countries, time zones, people, tags. The filter input is the core of the control.
- A name the user already knows. Typing beats scrolling when people can recall the option and only need to find it.
- Remote or changing data. Filter on the server and show loading and error states in the popup (Async loading).
- Several values from the same list with
multiple: chosen values become removable chips, and every chip comes from the option list. - Letting people add a value that is not listed. A "Create" row promotes the typed text to a real, selected option (Creatable).
When not to use
- For a short, known list. Use Select. It is also the right choice when the trigger should show the chosen value as a button rather than an editable input; its
multiplealready summarises the count. - For two to four choices that should stay visible. Use Radio or SegmentedControl.
- For commands and navigation. A centred overlay of actions with shortcut hints is Command. Rows that act (delete, invite, navigate) belong in DropdownMenu.
- For a search box. A page-level search with history, scopes and a submit action is Search; the same field with a suggestions popup, where nothing is committed, is SearchSuggest. The
combobox-recentsexample shows where the boundary runs. - For free-form tokens. When arbitrary text becomes chips (emails, keywords) with paste and comma splitting, use Tokenizer.
- For a compact unit or currency selector attached to an input. Use FieldDropdown.
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.
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. Each chip's remove button is a square of the chip's height, so it is at least 24px under a mouse; on touch its target is 44px, and wrapped chip rows part far enough that no row covers the one above it. A chip never grows past its row: in a narrow field a value wider than the row ellipsizes, and the full value stays the chip's accessible name and shows in the open list.
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.
Underline variant
variant="underline" draws the Field family's bottom-rule shell: square
whatever rounded says, no fill in any state, and focus or an open popup shown
as an inset 2px bottom band in the intent colour instead of the ring.
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.
RTL
dir="rtl" mirrors the field: text starts on the right, and the clear button and chevron move to the left end. The positioner receives the same dir, so the popup lines up with the field's right edge. Leave dir unset and the combobox inherits the page's direction.
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, name, form, …). form
reaches the hidden input, so a combobox rendered outside its <form> element
still submits with it. 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" | "filled" | "ghost" | "underline" | "outline" | Bordered well, a tinted well with no outline until focus or an intent, borderless toolbar field, or the square bottom rule that focuses and opens with an inset band (ignores rounded) |
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" | inherited | Logical direction. Stamped only when set; unset, read off the field and fed to Base UI and the popup |
strings | Partial<ComboboxStrings> | English, Arabic under dir="rtl" | Override the trigger button's name — showSuggestions, or showSuggestionsFor ("Show {label} suggestions") when the combobox has a label — the clear button's (clear, clearField), each chip's remove button (remove) and the optional marker. The language comes from dir alone: leave it unset and the combobox inherits the page's direction but keeps English |
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, Safari) | Failed 2026-09-14 on one path. Typing to filter, or opening with the trigger, reads each option ("Apple (1 of 10)", "Banana (2 of 10)") and the committed value. Opening the empty input with ArrowDown reads "expanded list 10 items" and then no option at all: the list opens and highlights in the same moment, and VoiceOver does not follow the highlight into a popup that has just appeared. Reported upstream as mui/base-ui#5721; until it is fixed, type a character or use the trigger. Transcript: tests/qa/vo-transcripts/combobox.md |
| NVDA | Untested — cannot run on this hardware |
| JAWS | Untested — cannot run on this hardware |
Known limitations
- VoiceOver + Safari: ArrowDown on the empty input reads no options. Base UI opens the list and highlights an option in the same moment, and VoiceOver does not follow that highlight into a popup that has just appeared. Typing a character or using the trigger reads every option. Tracked upstream in mui/base-ui#5721.
- 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 panel radius.