Combobox
betav0.4.0Filterable selection field: the user narrows a list by typing directly in the field, with `multiple` as a prop rather than a second component. It replaces MUI Autocomplete and the `<Select multiple>` sites Radix Select structurally cannot serve — 93 authored Autocomplete elements and 12 multi-select sites measured across OpenCTI and OpenAEV. The chevron says the value stays in the field, as opposed to SearchField's magnifier, which says the value acts. Behaviour is in-house on `useCombobox` (virtual focus through aria-activedescendant, DOM focus never leaving the input); @radix-ui/react-popover supplies positioning, dismissal and portalling only. In `multiple` mode the field holds one library Chip per value, wrapping onto as many rows as needed, and the options carry the library Checkbox in its presentational form. Filtering always belongs to the consumer: the default is a plain substring match and `filterOptions` replaces it wholesale, with no debouncing or re-ranking of its own.
Variants
- phishing
- ransomware
- exfiltration
- credential-access
- lateral-movement
The field grows; nothing is truncated
- phishing
- ransomware
- exfiltration
- credential-access
- lateral-movement
Bounded wash per option; uncoloured labels keep the default tone
- TLP:AMBER
Pick an actor before saving
- TLP:GREEN
Helper stays under the field
Examples
// Single value, client-side filtering. The field shows the label of the value
// it is given, and shows it again when focus leaves — nothing to wire for that.
// Selection on pointer focus is on by default; openOnFocus is the opt-in one.
const [actor, setActor] = useState<Actor | null>(actors[0] ?? null);
<Combobox
options={actors}
value={actor}
onValueChange={setActor}
openOnFocus
getOptionLabel={(a) => a.name}
isOptionEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxLabel>Threat actor</ComboboxLabel>
<ComboboxField>
<ComboboxInput placeholder="Search actors…" />
<ComboboxControls>
<ComboboxClear />
<ComboboxTrigger />
</ComboboxControls>
</ComboboxField>
<ComboboxContent />
</Combobox>// Multiple values: chips in the field, checkboxes in the list
<Combobox multiple options={labels} value={selected} onValueChange={setSelected} getOptionLabel={(l) => l.value}>
<ComboboxLabel>Labels</ComboboxLabel>
<ComboboxField>
<ComboboxChips />
<ComboboxInput placeholder="Add a label…" />
<ComboboxControls>
<ComboboxClear />
<ComboboxTrigger />
</ComboboxControls>
</ComboboxField>
<ComboboxContent />
</Combobox>// Server-side search: the consumer owns the query, the debounce and the filter
const [term, setTerm] = useState("");
const { data, isLoading } = useSearch(useDebounced(term, 300));
<Combobox
options={data ?? []}
value={value}
onValueChange={setValue}
inputValue={term}
onInputChange={setTerm}
filterOptions={(options) => options}
loading={isLoading}
getOptionLabel={(entity) => entity.name}
>
<ComboboxField>
<ComboboxInput placeholder="Search entities…" />
<ComboboxControls>
<ComboboxTrigger />
</ComboboxControls>
</ComboboxField>
<ComboboxContent emptyMessage="No entity found" loadingMessage="Searching…" />
</Combobox>// Grouped, with rich rows — the row element, its role and its state stay the component's
<Combobox
options={entities}
value={value}
onValueChange={setValue}
groupBy={(entity) => entity.type}
getOptionLabel={(entity) => entity.name}
renderOption={(entity, { selected }) => (
<span className="flex flex-col">
<Text>{entity.name}</Text>
<Text variant="caption">{selected ? "Selected" : entity.description}</Text>
</span>
)}
>
<ComboboxField>
<ComboboxInput />
<ComboboxControls>
<ComboboxTrigger />
</ComboboxControls>
</ComboboxField>
<ComboboxContent />
</Combobox>// Chips, no list: the field is a chip entry, and the panel never opens.
// `open={false}` with no onOpenChange means nothing can move the panel, so
// none is mounted and the arrows keep their native meaning in the input.
<Combobox
multiple
open={false}
options={[]}
value={emails}
onValueChange={setEmails}
allowCustomValue
createValueFromInput={(email) => email}
getOptionLabel={(email) => email}
>
<ComboboxLabel>Reply-to addresses</ComboboxLabel>
<ComboboxField>
<ComboboxChips />
<ComboboxInput placeholder="Type an address, then Enter…" />
</ComboboxField>
<ComboboxContent />
</Combobox>// One line: as many chips as the width holds, the rest in "+N" (a number caps it).
<Combobox multiple options={roles} value={selected} onValueChange={setSelected} maxVisibleChips="fit">
<ComboboxLabel>Roles</ComboboxLabel>
<ComboboxField>
<ComboboxChips />
<ComboboxInput />
</ComboboxField>
<ComboboxContent />
</Combobox>Props
| Prop | Type |
|---|---|
Combobox.options | T[] — The options to render. Objects, not strings: products pass entities |
Combobox.value | T | T[] | null — Controlled value: the option object in single mode, an array in multiple, null/[] when empty |
Combobox.onValueChange | (value: T | T[] | null) => void — Fires with the new value; there is no uncontrolled mode |
Combobox.multiple | boolean — Chips in the field, checkboxes in the list, and the panel stays open on select |
Combobox.getOptionLabel | (option: T) => string — Visible label of an option, and what the default filter matches. Defaults to String(option) |
Combobox.isOptionEqualToValue | (a: T, b: T) => boolean — Identity for the controlled comparison. Defaults to Object.is, so pass it whenever options are re-created from a query |
Combobox.isOptionDisabled | (option: T) => boolean — Marks a row aria-disabled and unselectable |
Combobox.filterOptions | (options: T[], inputValue: string) => T[] — Replaces the default substring match wholesale, order included. Return the options as-is for server-side search |
Combobox.groupBy | (option: T) => string — Presence turns on group headers, in first-appearance order |
Combobox.inputValue | string — Controlled search text |
Combobox.onInputChange | (value: string) => void — Fires on every keystroke, undebounced: the consumer owns the timer |
Combobox.loading | boolean — Marks the listbox aria-busy and shows the loading row |
Combobox.closeOnSelect | boolean — multiple mode only: close the panel after each pick. Default false |
Combobox.keepInputOnBlur | boolean — keep the typed text on blur. Default false |
Combobox.allowCustomValue | boolean — accept a value the option list does not contain. Needs createValueFromInput |
Combobox.createValueFromInput | (input: string) => T — builds a value from the typed text. Required with allowCustomValue |
Combobox.onCreateOption | (input, meta) => void — creates a real entity from the typed text; makes the create row appear |
Combobox.open | boolean — Controlled panel state; omit it and the component owns the panel. Supplied, every internal open/close becomes a request reported through onOpenChange with its cause, and openOnFocus is ignored. Supplied without onOpenChange (the chips-only field: open={false}, no <ComboboxContent />), nothing can move the panel, so none is mounted and the opening keys stay native |
Combobox.onOpenChange | (open, meta) => void — panel open/close, with its cause |
Combobox.disabled | boolean — Inert field, panel cannot open |
Combobox.clearable | boolean — Shows the clear control once there is something to clear (default: true) |
Combobox.selectOnFocus | boolean — When a pointer brings focus into the field, selects its text so the next keystroke replaces the value. ON by default, pass false to opt out. Pointer only: browsers already select on Tab, and opting out does not change that |
Combobox.openOnFocus | boolean — Opens the panel when the field takes focus, keyboard included (default: false). A click already opened it before this prop existed |
Combobox.error | boolean — Error border on the field, error token on the helper text |
Combobox.labelPosition | 'top' | 'left' | 'none' — where ComboboxLabel sits. Default 'top', the shipped layout: omitting the prop changes nothing. 'left' puts the label beside the field, on the field's own row, with the helper text under the field; it hugs and never wraps, so give it a width through className to line several fields up. 'none' renders no label element, so ComboboxInput MUST carry an aria-label or aria-labelledby — a dev-only warning fires otherwise |
Combobox.renderOption | (option: T, state: { selected: boolean; active: boolean }) => ReactNode — Rich row content; the row element, its role and its state stay the component's |
Combobox.getChipColor | (option: T) => string | undefined — Multiple mode only: the DATA colour of each selected value's chip, a free hex the option itself carries (an OpenCTI label's or marking definition's user-chosen colour). Handed straight to Chip's `color`, so the library applies the same bounded wash and keeps label, icon and border on system tokens (Chip.rfc.md §9.4). Return undefined for an option with no colour — that chip renders exactly as today, so a list mixing coloured and uncoloured options needs no branching. Presentation only: it never reaches the selection engine, and without it every chip is unchanged |
Combobox.maxVisibleChips | number | 'fit' — Multiple mode only: holds the selection to one line, the rest in a `+N` chip. 'fit' shows as many chips as the width holds; a number is at most that many, fewer when they would not fit (see minChipWidth). With several values and no room for 72px of the first chip, `+N` stands alone. Omitted, chips wrap as before |
Combobox.minChipWidth | number — With maxVisibleChips: the width in px below which a chip after the first is not cut (the first keeps 72px). Default 96, from the spacing scale; a number you pass is px. It makes a numeric cap an upper bound. 0 opts out: a cap is then that many chips squeezed down to a chip with no label, fewer when even that does not fit |
ChipWidthsProvider | Measures every option's chip and every `+N` once, off-screen, for the Comboboxes below it (styled alike), so recycled or virtualized rows measure nothing. For a bounded option set; children mount once the widths are in. Props: labels (string[]), maxSelected (number, default every label) |
ComboboxLabel | Renders a <label htmlFor> wired to the input. Its box follows Combobox's labelPosition; not rendered at all under 'none' |
ComboboxLabel.required | boolean — shows the required indicator (*), aria-hidden so it stays out of the accessible name. Same rendering as SelectLabel's. Independent from Combobox's own required (aria-required) |
ComboboxField | The field shell. Clicking anywhere in it focuses the input and opens the panel. Takes two optional slots on the field line, in this order: startIcon -> chips -> text -> adornment -> ComboboxClear -> ComboboxTrigger |
ComboboxField.startIcon | ReactNode — leading decorative glyph, iso with SearchField's magnifier (16px, 8px to the text, input-value tone, input-disabled when the field is). aria-hidden, never a tab stop, and a press on it reads as a press on the field |
ComboboxField.adornment | ReactNode — the host's own control on the field line, after the text and before the clear and the chevron. Reachable in the natural tab order, keeps its own pointer behaviour (pressing it neither opens the panel nor refocuses the input), inert on a disabled field. Takes an IconButton size=sm priority=tertiary — never primary. It spends its width (~28px) instead of overlaying the text, so the text truncates earlier, and it reserves nothing when its content renders null |
ComboboxChips | One library Chip per selected value; renders nothing in single mode. With maxVisibleChips on the root it renders one line and a `+N` chip instead |
ComboboxChips.overflowLabel | (count: number, labels: string[]) => string — with maxVisibleChips: the `+N` chip's accessible name, default `+<count> more: <labels>`. It always starts with the visible `+<count>` (WCAG 2.5.3): return the words after it, or a name that starts with it |
ComboboxInput | The role=combobox input carrying every ARIA relationship |
ComboboxClear | Clear-all control; absent until there is a value, and never replaces the chevron |
ComboboxTrigger | The chevron. Not a Tab stop of its own |
ComboboxContent | The panel and its listbox. Accepts emptyMessage, loadingMessage, listAriaLabel and listClassName |
ComboboxHelperText | Helper text below the field, wired through aria-describedby |
Accessibility
Contrast: the disabled field text on the disabled field fill (--text-default-disabled on --bg-input-disabled) is permanently exempt — WCAG 1.4.3 Note 1 excludes inactive user interface components — so no contrast pair is declared for it, deliberately and not by omission. Measured 4.35:1 dark / 3.69:1 light since the 2026-08-20 export moved both tokens onto the gray ramp. Same non-declaration as Input, SearchField, Select and Menu, which render the identical pairing. DATA COLOURS: `getChipColor` hands each selected option's own hex to its Chip's `color`, so the bound, the system label/icon/border tokens and the check:contrast Rule 6 proof are Chip's, not this component's — nothing here paints a data colour itself (Chip.meta.ts accessibility.dataColorWash). Presentation only, like renderOption: it never reaches the selection engine, an option returning undefined keeps the default tone, and a Combobox without the prop renders the chip row it always did. ONE-LINE OVERFLOW (maxVisibleChips): every chip, `+N` included, is a library Chip, colour pairs and focus ring included. `+N` is its own Tab stop before the input (disabled: a named role=img out of the Tab order), named `+<count> more: <labels>` with a tooltip of the hidden labels; Enter or Space on it opens the list. The arrow keys walk only the drawn chips. The row is not live (a re-fit would be read out): a hidden role=status announces the labels a selection change adds. WAI-ARIA 1.2 combobox with list autocomplete. The input carries role=combobox, aria-expanded computed from the real panel state, aria-controls only while the listbox exists, aria-activedescendant naming the highlighted option, and aria-autocomplete="list" — the value APG specifies for "Editable Combobox With List Autocomplete", overridable by the consumer. DOM focus stays in the input at all times: the highlight is virtual. Options carry aria-selected reflecting SELECTION, never the highlight, and several are true at once in multiple mode, where the listbox is aria-multiselectable. Three named non-regression tests lock exactly these three attributes (Combobox.a11y.test.tsx) because an off-the-shelf engine got all three wrong — see Combobox.rfc.md §9 Q5. The chip row is a single Tab stop navigated with arrow keys (Chip's deleteTabIndex={-1}); it announces changes through aria-live=polite. The popup is not a dialog: Radix's own role is removed so the listbox alone carries semantics. The checkbox inside an option is Checkbox's presentational form, because a real control nested in a role=option is an axe nested-interactive failure. axe runs on the OPEN panel in both modes, which the auto-discovered suite cannot reach on its own. In single mode the input's text is DERIVED from the selection and re-derived when focus leaves the field, so the field can never announce or submit a string that no longer matches its value (Combobox.rfc.md §5.5); the consumer-owned inputValue and multiple mode are outside that rule. Selection on pointer focus is ON by default (selectOnFocus={false} opts out) and is the pointer path only — the browser already selects an input's content when Tab moves focus in, which opting out does not suppress either, since that selection is the browser's and not this component's. DOM focus never leaves the input, pointer included: both the field and the panel cancel the default focus move of a mouse-down that lands anywhere but the input, so neither the chevron, the clear cross nor an option row can take focus — a row could, and the arrow keys then did nothing at all until the next click (§5.1). The panel's scroll container is exempt so its scrollbar keeps its default. The panel also follows the highlight on the KEYBOARD path — each move scrolls its row into view with block:nearest, no animation — and deliberately not on the pointer path, where the row is already under the cursor. All of these are browser-gated in docs/e2e/combobox-focus.spec.ts: jsdom reproduces none of them.