Skip to main content

Combobox

betav0.4.0

Filterable 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

Single value, client-side filterOpenCTI AutocompleteField — the biggest surface, 54 call sites across 46 files. Mounted on a value; selection on pointer focus is the default, openOnFocus is passed
Multiple, chips wrapping onto a second rowOpenCTI ObjectLabelField / OpenAEV TagField — 45 of 93 authored elements carry multiple
  • phishing
  • ransomware
  • exfiltration
  • credential-access
  • lateral-movement

The field grows; nothing is truncated

Multiple, held to one linemaxVisibleChips — the node's own one-line +N (§9 Q14). Two fields, a count of 1 and fit, share one selection on purpose: edit either and the other follows, so only the display differs
  • phishing
  • phishing
  • ransomware
  • exfiltration
  • credential-access
  • lateral-movement
Multiple, chips coloured by the DATAgetChipColor — OpenCTI ObjectLabelField: every label carries its own hex
  • phishing
  • ransomware
  • exfiltration
  • credential-access
  • lateral-movement

Bounded wash per option; uncoloured labels keep the default tone

Grouped optionsgroupBy — 9 OpenCTI + 6 OpenAEV authored elements
Server search on the keystroke only28 sites. Log fires on typing only — never on a pick or a clear

Closing on each pick, and text surviving blurdisableCloseOnSelect 4 sites · clearOnBlur=false 3 OpenAEV sites
Creating the value that is not in the listNodes 6920:11382 click / 6920:11841 Enter — 8 create sites, 12 freeSolo

Unselectable rowsOpenCTI ImportFilesList — connectors that cannot handle the file stay visible but inert
Loading, server-side searchAsync search on keystroke — 2 OpenCTI + 3 OpenAEV, driven by the consumer's own debounce
No resultsTyped text matching nothing — the empty row is announced politely
120 options, scrollingLong lists are why Select stops at ~15 — and why ListboxComponent is 0 of 93: no virtualization needed
Closed list, multiple — the Select multiple replacementThe 12 enumerated <Select multiple> sites (5 OpenCTI + 7 OpenAEV): short static list, chips, no server call
  • TLP:AMBER
Error stateForm validation — the field takes the error border, the helper text the error token

Pick an actor before saving

Disabled fieldRead-only forms — the panel cannot open and the chips stay muted
  • TLP:GREEN
Rich rowsrenderOption — 34 OpenCTI + 23 OpenAEV authored elements render icon-bearing or two-line rows
Label position — top, left, noneNode 6899:15536, the labelPosition axis (left: node 7333:46651). The asterisk is ComboboxLabel's required, aria-hidden

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

PropType
Combobox.optionsT[] — The options to render. Objects, not strings: products pass entities
Combobox.valueT | 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.multipleboolean — 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.inputValuestring — Controlled search text
Combobox.onInputChange(value: string) => void — Fires on every keystroke, undebounced: the consumer owns the timer
Combobox.loadingboolean — Marks the listbox aria-busy and shows the loading row
Combobox.closeOnSelectboolean — multiple mode only: close the panel after each pick. Default false
Combobox.keepInputOnBlurboolean — keep the typed text on blur. Default false
Combobox.allowCustomValueboolean — 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.openboolean — 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.disabledboolean — Inert field, panel cannot open
Combobox.clearableboolean — Shows the clear control once there is something to clear (default: true)
Combobox.selectOnFocusboolean — 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.openOnFocusboolean — Opens the panel when the field takes focus, keyboard included (default: false). A click already opened it before this prop existed
Combobox.errorboolean — 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.maxVisibleChipsnumber | '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.minChipWidthnumber — 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
ChipWidthsProviderMeasures 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)
ComboboxLabelRenders a <label htmlFor> wired to the input. Its box follows Combobox's labelPosition; not rendered at all under 'none'
ComboboxLabel.requiredboolean — 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)
ComboboxFieldThe 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.startIconReactNode — 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.adornmentReactNode — 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
ComboboxChipsOne 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
ComboboxInputThe role=combobox input carrying every ARIA relationship
ComboboxClearClear-all control; absent until there is a value, and never replaces the chevron
ComboboxTriggerThe chevron. Not a Tab stop of its own
ComboboxContentThe panel and its listbox. Accepts emptyMessage, loadingMessage, listAriaLabel and listClassName
ComboboxHelperTextHelper text below the field, wired through aria-describedby

Accessibility

2.1 AAWCAG pass

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.