Combobox
A combobox is a text input that filters a list of options. It is Headless
UI's Combobox in a styled frame: the input takes the field tokens, the
panel the bg-popover surface shared with Select, Menu and Popover. It is
generic over the value type and over multiple.
import Combobox from '@zuilib/primitives/combobox'
Convenience mode
Pass options and the component renders the input, the chevron button and
the list. Typing filters by a case-insensitive label match; the panel
shows emptyMessage when nothing matches.
Sizes
sm, md and lg share the control tokens with Input and Select. The
chevron sits half the control padding from the edge, so a padding retheme
moves both.
Multiple
multiple turns value and onValueChange into arrays. The input shows ''
by default; pass displayValue to summarise the selection.
In a Field
Inside a Field the input takes the field's id, so Label and
FieldDescription wire themselves up, and invalid / disabled are
inherited when left unset. Do not pass id inside a Field.
Compound mode
Omit options and compose the parts yourself. Filtering is then yours:
Combobox.Input reports the query through onChange. Object values need
compareBy so equality survives re-renders.
Props
ComboboxProps<T, Multiple>:
| Prop | Type | Default | Description |
|---|---|---|---|
value | T | null (T[] when multiple) | — | Controlled value; null / [] for nothing selected |
defaultValue | T | null (T[] when multiple) | — | Uncontrolled initial value |
onValueChange | (value) => void | — | |
options | Array<{value: T; label: string; disabled?: boolean}> | — | Convenience mode. Labels are the React keys, keep them unique |
children | ReactNode | — | Compound mode; ignored when options is given |
placeholder | string | 'Search...' | |
multiple | boolean | false | Value and onValueChange become arrays |
compareBy | keyof T | (a: T, b: T) => boolean | — | Object-value equality; reference equality by default |
size | 'sm' | 'md' | 'lg' | 'md' | |
fullWidth | boolean | true | false shrinks the root to the input |
invalid | boolean | — | Defaults to the enclosing Field's invalid; sets aria-invalid + data-invalid |
disabled | boolean | — | Leave unset inside a Field to inherit |
anchor | placement | {to, gap, offset, padding} | false | 'bottom start' | Panel placement; false renders an inline panel |
portal | boolean | true when anchored | only honoured with anchor={false}; Headless UI portals every anchored panel. Use anchor={false} to keep the panel in the subtree so token overrides reach it |
openOnFocus | boolean | false | Open on focus |
onQueryChange | (query: string) => void | — | Every keystroke, and '' on close |
filter | false | (option, query) => boolean | case-insensitive label match | false for server-side filtering |
loading | boolean | false | Replaces the list with a spinner row |
loadingMessage | ReactNode | 'Loading…' | |
emptyMessage | ReactNode | 'No results found.' | Shown whenever no option renders |
renderOption | (option, {focus, selected, disabled}) => ReactNode | — | Custom option body (convenience mode) |
displayValue | (value) => string | option label (single) / '' (multiple) | Text shown in the input for the value |
name | string | — | Hidden inputs for native form submission |
form | string | — | |
id | string | — | Id of the input. Only outside a Field |
autoFocus | boolean | — | |
aria-label | string | — | Accessible name without a visible Label |
aria-describedby | string | — | Merged with the Field description ids |
toggleLabel | string | 'Toggle options' | Accessible name of the chevron button; the default comes from Labels.toggleOptions |
track | string | — | Names the select telemetry event; falls back to name, then aria-label |
className | string | — | Merged onto the root wrapper |
placeholder, loadingMessage and emptyMessage default to the
Labels strings search, loadingMessage and noResults, so a
LabelsProvider
translates them once.
Parts
Combobox.Input:size?,displayValue?(value),onChange?(event),defaultValue?: stringplus input attributes (Headless UI ownsrole,aria-*,value).Combobox.Button:size?,toggleLabel?,childrenreplace the chevron. NamedtoggleLabel(root prop, part prop orLabels.toggleOptions, defaultToggle options) outside a Field.Combobox.Options:anchor?,portal?,keepMounted?,keepHighlightOnPointerLeave?,modal?,transition?.Combobox.Option:value,disabled?,size?,childrenor(state) => ReactNode.
Parts inherit size, anchor, portal, invalid and loading from the root.
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="combobox"] | div | Root. Also data-size; Headless UI adds data-open, data-disabled, data-invalid |
[data-slot="combobox-input"] | input | — |
[data-slot="combobox-button"] | button | Chevron toggle, absolutely positioned at the right edge |
[data-slot="combobox-chevron"] | svg | Rotates while open |
[data-slot="combobox-options"] | div | The listbox panel |
[data-slot="combobox-option"] | div | — |
[data-slot="combobox-loading"] | div | Spinner row while loading |
[data-slot="combobox-empty"] | div | The emptyMessage row |
[data-slot="combobox-status"] | span | Visually hidden live region for loading / empty text |
Accessibility
- The input has
role="combobox"witharia-expanded,aria-controls,aria-autocompleteandaria-activedescendantmanaged by Headless UI; the panel isrole="listbox"and each rowrole="option"witharia-selected. - Arrow keys move the active option, Enter selects, Escape closes; Alt+Down opens without changing the selection. Typing filters.
- Inside a
Field,Labelgives the input its name andFieldDescription/FieldErrorland inaria-describedby. Outside one, passaria-label. invalidsetsaria-invalidand paints the input with the danger colour; the loading and empty rows are disabled options and are also announced from thecombobox-statuslive region.- The chevron button is not in the tab order (Headless UI sets
tabIndex=-1); keyboard users open the list from the input.
Related
- Select: the same panel without a text input.
- NativeSelect: the native control for short lists.
- Field, Label, FieldDescription, FieldError: the field wrapper and its parts.