Source: https://monarchui.dev/components/combobox/

Components · Forms

# Combobox

Searchable single or multiple select with groups, async search, a create option and an inline mode.

@rookcom/monarch-react

Component

## Searchable picker

## Usage examples

### Multiple with groups and hints

### Server search with a typed fallback

### Inline list

## 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 `onSearch` and 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-activedescendant` pattern (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

```tsx
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; `sortByMatch` ranks prefix matches first).
- Clicking an option selects it. Single mode closes the list; `multiple` toggles the value and keeps it open.
- With `onSearch`, opening searches the empty query at once; typing searches after `searchDebounce` ms (default 250).
- With `onCreate`, a "Create “text”" option appears when no label matches the text exactly.
- `clearOption` adds a first option that clears the selection.
- `limit` renders 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 by

listLabel

, the Field label or

aria-label

.

- `aria-autocomplete="list"`, `aria-expanded`, `aria-controls` and `aria-activedescendant` are set on the input.
- Grouped options sit in `role="group"` elements labelled by their heading.
- With `multiple` the listbox is `aria-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 `multiple` mode 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 `onSearch` for server lists rather than wiring `inputValue` and `filter={() => true}` by hand.
- Pass `valueOptions` when a selected value may not be in the current results, so it keeps its label.
- Write an `emptyMessage` that 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 `onSearch` or `limit`.

## Related components

- [Select](https://monarchui.dev/components/select/): Short lists without search.
- [EntityPicker](https://monarchui.dev/components/entity-picker/): Button-triggered record picker built on the inline Combobox.
- [TagInput](https://monarchui.dev/components/tag-input/): Free-form values as chips.
- [Field](https://monarchui.dev/components/form-field/): Label and error wiring.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `@radix-ui/react-popover``react` |
| Monarch components | [Field](https://monarchui.dev/components/form-field/), [Spinner](https://monarchui.dev/components/spinner/) |

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`

## All example code

### Searchable picker

```tsx
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 />
```

### Multiple with groups and hints

```tsx
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

```tsx
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

```tsx
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>
```
