Components · Forms
Combobox
Searchable single or multiple select with groups, async search, a create option and an inline mode.
Searchable picker
import { useState } from 'react';
import { Combobox, Field, FieldLabel } from '@rookcom/monarch-react';
const timezones = [
{ value: 'asia-kolkata', label: 'Asia/Kolkata' },
{ value: 'europe-london', label: 'Europe/London' },
{ value: 'america-new-york', label: 'America/New_York' },
{ value: 'asia-tokyo', label: 'Asia/Tokyo' },
{ value: 'utc', label: 'UTC', description: 'Coordinated Universal Time' },
];
function TimezonePicker() {
const [value, setValue] = useState<string | null>('utc');
return (
<Field style={{ width: '100%', maxWidth: 300 }}>
<FieldLabel>Time zone</FieldLabel>
<Combobox options={timezones} value={value} onValueChange={setValue} placeholder="Search" />
</Field>
);
}
<TimezonePicker />Usage examples
Multiple with groups and hints
import { useState } from 'react';
import { Combobox, Field, FieldLabel } from '@rookcom/monarch-react';
import type { ComboboxOption } from '@rookcom/monarch-react';
const reviewers: ComboboxOption[] = [
{ value: 'u1', label: 'Priya Raman', hint: 'Design', group: 'In this team' },
{ value: 'u2', label: 'Jonas Weber', hint: 'Platform', group: 'In this team' },
{ value: 'u3', label: 'Amara Okafor', group: 'Everyone else' },
{ value: 'u4', label: 'Lucas Martin', group: 'Everyone else' },
{ value: 'u5', label: 'Mei Chen', group: 'Everyone else' },
];
function ReviewersPicker() {
const [values, setValues] = useState(['u1', 'u3']);
return (
<Field style={{ width: '100%', maxWidth: 340 }}>
<FieldLabel>Reviewers</FieldLabel>
<Combobox
multiple
options={reviewers}
values={values}
onValuesChange={setValues}
sortByMatch
placeholder="Add a reviewer"
/>
</Field>
);
}
<ReviewersPicker />Server search with a typed fallback
import { useState } from 'react';
import { Combobox, Field, FieldDescription, FieldLabel } from '@rookcom/monarch-react';
import type { ComboboxOption } from '@rookcom/monarch-react';
const CITIES = ['Lisbon', 'London', 'Lyon', 'Lagos', 'Lima', 'Leeds', 'Lucknow', 'Ljubljana'];
/** A pretend server: answers after a short delay and honours the abort signal. */
function searchCities(query: string, { signal }: { signal: AbortSignal }) {
return new Promise<ComboboxOption[]>((resolve, reject) => {
const timer = setTimeout(() => {
const q = query.toLowerCase();
resolve(
CITIES.filter((c) => c.toLowerCase().includes(q)).map((c) => ({ value: c, label: c })),
);
}, 300);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
function CityPicker() {
const [value, setValue] = useState<string | null>(null);
const [created, setCreated] = useState<ComboboxOption[]>([]);
return (
<Field style={{ width: '100%', maxWidth: 300 }}>
<FieldLabel>City</FieldLabel>
<Combobox
onSearch={searchCities}
value={value}
onValueChange={setValue}
valueOptions={created}
onCreate={(q) => {
const option = { value: q, label: q };
setCreated((list) => [...list, option]);
return option;
}}
messages={{ create: (q) => `Use “${q}”` }}
clearOption={{ label: 'No city' }}
placeholder="Start typing"
/>
<FieldDescription>
Results come from a server; typing aborts the last request.
</FieldDescription>
</Field>
);
}
<CityPicker />Inline list
import { Combobox } from '@rookcom/monarch-react';
import type { ComboboxOption } from '@rookcom/monarch-react';
const reviewers: ComboboxOption[] = [
{ value: 'u1', label: 'Priya Raman', hint: 'Design', group: 'In this team' },
{ value: 'u2', label: 'Jonas Weber', hint: 'Platform', group: 'In this team' },
{ value: 'u3', label: 'Amara Okafor', group: 'Everyone else' },
{ value: 'u4', label: 'Lucas Martin', group: 'Everyone else' },
{ value: 'u5', label: 'Mei Chen', group: 'Everyone else' },
];
<div
style={{
width: '100%',
maxWidth: 300,
border: '1px solid var(--mn-color-border-subtle)',
borderRadius: 'var(--mn-radius-lg)',
}}
>
<Combobox
inline
aria-label="Search people"
listLabel="People"
options={reviewers}
defaultValue="u2"
placeholder="Search…"
/>
</div>Purpose
A select people type into: the input is the combobox and the options are a listbox. It covers long static lists, server search with debounce and abort, several values as chips, and the inline list that EntityPicker and TimezoneSelect build on.
Use when
- The list is long enough that people search rather than scan.
- Options come from a server: pass
onSearchand Combobox handles debounce, abort, loading and errors. - Several values from a known list:
multiple. - A picker list inside a Popover, Dialog or Sheet:
inline.
Avoid when
- A short, fixed list: use Select.
- A record picker opened from a button or chip: use EntityPicker.
- Free-form values with no list: use TagInput.
Design principles
- The input keeps focus; options follow the
aria-activedescendantpattern (WAI-ARIA APG combobox). - In single mode the input always reflects the selection: unmatched text is reverted on blur and Escape.
- The product owns data and rules: search, creation and option content come in through props.
- Late server answers never overwrite newer ones: a new query aborts the last request.
API reference
import { Combobox, matchComboboxOption } from '@rookcom/monarch-react';| Export | Kind | Rendering | Root class |
|---|---|---|---|
Combobox | component | client | .mn-combobox- |
matchComboboxOption | utility | client | – |
Forwards its ref to the <input>. Native input attributes pass through, except value, defaultValue, onChange, size and type.
matchComboboxOption(option, query) is the default filter, exported for products that extend it.
Single mode uses value / defaultValue / onValueChange; multiple uses values / defaultValues / onValuesChange.
Generated from the TypeScript declarations of packages/react/src/components/forms/combobox.
Properties
onSearch(query, { signal }) returns a promise of options. Pass the signal to fetch so superseded requests are cancelled.
onCreate(query) may return the new option (or a promise of it) to select it.
Combobox
Searchable select. The input is the combobox and the options are a listbox (WAI-ARIA APG combobox pattern with `aria-activedescendant`): single or multiple selection, grouped options with media and hints, async search with debounce and abort, a create option and an inline mode for pickers. Keyboard: Down/Up move, Home/End jump, PageDown/PageUp skip ten, Enter selects, Escape closes, Alt+Down/Alt+Up open and close, typing filters.
| Prop | Type | Description |
|---|---|---|
children | ReactNode | |
className | string | |
clearable | boolean | Allow clearing the selection with the clear button / Backspace on empty input. |
clearOption | ComboboxClearOption | The list starts with an option that clears the selection, such as "Unassigned" or "No country". |
closeOnSelect | boolean | Close the list after a pick. Default: true, or false with `multiple` or `inline`. |
defaultOpen | boolean | |
defaultValue | string | null | |
defaultValues | string[] | |
disabled | boolean | |
emptyMessage | ReactNode | Shown when no option matches. |
filter | ((option: ComboboxOption, query: string) => boolean) | Custom filter. Default: case-insensitive match on label, hint and keywords. Pass `() => true` for server-filtered lists. |
id | string | |
inline | boolean | Show the list inline under a search input, always open, instead of in a popover: for pickers inside a Popover, Dialog or Sheet. The input is a search box: it never shows the selected label. |
inputValue | string | Text typed in the input (controlled). Use for server-side filtering. |
invalid | boolean | |
limit | number | Render at most this many matching options, with a message to keep typing. Default: all. |
listLabel | string | Accessible name of the listbox. Default: the Field label, `aria-label`, or "Options". |
loading | boolean | Shown while options load. |
messages | Partial<ComboboxMessages> | Interface text, for translation. |
minSearchLength | number | Shortest query `onSearch` runs for; shorter queries show `emptyMessage`. Default 0. |
multiple | boolean | Select several values. Use `values`, `defaultValues` and `onValuesChange`; the list stays open after each pick. |
onCreate | ((query: string) => ComboboxOption | null | undefined | Promise<ComboboxOption | null | undefined>) | Adds a "Create “query”" option when the typed text matches no option's label. Return the new option to select it; return nothing to only close (single) or clear the text (multiple). |
onInputValueChange | ((text: string) => void) | |
onOpenChange | ((open: boolean) => void) | |
onSearch | ((query: string, context: ComboboxSearchContext) => Promise<ComboboxOption[]>) | Load options from a server. Called with the query (`''` when the list opens with nothing typed) after `searchDebounce` ms. A newer query aborts the previous call through `signal`, and late answers are ignored. Replaces `filter`. |
onValueChange | ((value: string | null) => void) | |
onValuesChange | ((values: string[]) => void) | |
open | boolean | Whether the list is open (controlled). Ignored with `inline`. |
options | ComboboxOption[] | The options. With `onSearch`, the options shown before the first search finishes (usually none). |
renderOption | ((option: ComboboxOption, state: ComboboxOptionState) => ReactNode) | Custom option content. Default: media, label, description, hint and a check mark. |
searchDebounce | number | Milliseconds to wait after typing before `onSearch`. Default 250. |
size | "sm" | "md" | "lg" | |
sortByMatch | boolean | Order matches by quality: labels that start with the query, then a word that does, then the rest. Default false (keep the list order). |
value | string | null | Selected option value (controlled). Single-select only. |
valueOptions | ComboboxOption[] | Options for the current value(s) when they may not be in `options`, for example after a server search. Used only for labels. Options seen in earlier results are remembered too; an unknown value shows as its raw string. |
values | string[] | Selected values (controlled), with `multiple`. |
Variants
| Prop | Values | Description |
|---|---|---|
size | 'sm''md''lg' | Height, matching Input sizes. Default md. |
multiple | 'false''true' | One value, or several shown as removable chips. |
inline | 'false''true' | List in a popover under the input, or always open under a search box. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Open | aria-expanded="true" | The listbox is shown. |
| Active option | data-highlighted | The option the keyboard or pointer is on. |
| Selected | aria-selected="true" | Shows a check indicator. |
| Disabled option | aria-disabled="true" | Cannot be selected. |
| Loading | aria-busy="true" on the listbox | A small Spinner in the input; "Loading…" while there are no options yet. |
| Error | data-tone="danger" on the status | onSearch rejected: a message with a "Try again" button. |
| Empty | – | emptyMessage (default "No matches") in a status region. |
| Invalid | data-invalid | Danger border, from invalid or the surrounding Field. |
| Disabled | data-disabled | Input and toggle are disabled; chips lose their remove buttons. |
Interaction behaviour
- Typing opens the list and filters it (label, hint and keywords;
sortByMatchranks prefix matches first). - Clicking an option selects it. Single mode closes the list;
multipletoggles the value and keeps it open. - With
onSearch, opening searches the empty query at once; typing searches aftersearchDebouncems (default 250). - With
onCreate, a "Create “text”" option appears when no label matches the text exactly. clearOptionadds a first option that clears the selection.limitrenders only the first matches and says how many are hidden.- Chip remove buttons and Backspace remove values; each change is announced in a polite status region.
- The chevron button toggles the list and returns focus to the input.
Keyboard support
| Keys | Action |
|---|---|
| ArrowDown / ArrowUp | Opens the list on the selected option, then moves the active option. |
| Alt+ArrowDown / Alt+ArrowUp | Opens without moving / closes the list. |
| PageDown / PageUp | Moves ten options. |
| Home / End | Jumps to the first or last option while open. |
| Enter | Selects (or toggles) the active option unless disabled; retries a failed search when nothing is listed. |
| Escape | Closes the list and restores the selected label. |
| Backspace | On an empty input: removes the last chip (multiple) or clears the selection (clearable). |
Accessibility
- Role
- combobox, controlling a listbox
- Accessible name
- A FieldLabel in the surrounding Field, or
aria-label. The listbox is named bylistLabel, the Field label oraria-label.
aria-autocomplete="list",aria-expanded,aria-controlsandaria-activedescendantare set on the input.- Grouped options sit in
role="group"elements labelled by their heading. - With
multiplethe listbox isaria-multiselectable; chips are a list with named remove buttons. - Loading, empty, error and "showing N of M" messages are
role="status". - Option media is decorative (
aria-hidden); the label names the option.
Responsive behaviour
- The popover list matches the input width (at least 12rem) and limits its height to the space available.
- In
multiplemode chips wrap onto new lines and the control grows; the text input keeps at least 5rem. - Long labels wrap; hints truncate with an ellipsis at 40% of the row.
Theme support
- Input, chip and menu colours are semantic tokens shared with Input, TagInput and DropdownMenu, so light, dark and more-contrast themes apply.
- The menu entrance animation comes from the foundation and respects reduced motion.
Do and don't
Do
- Use
onSearchfor server lists rather than wiringinputValueandfilter={() => true}by hand. - Pass
valueOptionswhen a selected value may not be in the current results, so it keeps its label. - Write an
emptyMessagethat says what to try next.
Don't
- Do not use Combobox for free text entry without
onCreate; single mode reverts unmatched text. - Do not render thousands of options: use
onSearchorlimit.
Related components
- Select: Short lists without search.
- EntityPicker: Button-triggered record picker built on the inline Combobox.
- TagInput: Free-form values as chips.
- Field: Label and error wiring.
Dependencies
Uses .mn-input from text-field and .mn-menu from the shared menu styles.
Testing coverage
- Filters, navigates with the keyboard (aria-activedescendant) and selects with Enter; Escape restores the selection.
- Grouped options in labelled groups; hint and keyword matching; prefix ranking; custom rendering; limit message; clear option.
- PageDown/PageUp, Home/End, Alt+ArrowDown/Up; Backspace clears.
- Multiple: toggles, stays open, chips with remove buttons, Backspace removes, announcements, disabled chips.
- Async: debounce, abort of superseded queries, late answers ignored, error with retry (button and Enter), minSearchLength, valueOptions labels.
- Create option selects what onCreate returns; hidden on an exact match. Inline mode.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/combobox; Cross-browser axe: /components/combobox |
Tests: packages/react/src/components/forms/combobox