Design System
Components

Combobox

Single-select field for option sets that are fetched, not fixed.

Use Combobox when the option list is too long to enumerate, comes from a search endpoint, or both. It implements the ARIA 1.2 combobox pattern — a text input paired with a popup listbox, with the active option tracked via aria-activedescendant rather than by moving DOM focus into the list, so the user can keep typing while arrowing through results.

Try typing “gold”.

Props

Prop

Type

Choosing between Combobox and Select

The design system ships two single-select dropdowns on purpose. They look the same — both wear the same 44px field shell — and differ only in interaction:

SelectCombobox
Use forshort, fixed, known-at-build-time sets — a status, a sort direction, a currencylong lists, or option sets fetched at runtime
Rendersa button and a listboxan ARIA 1.2 combobox: an editable text input and a popup listbox
Filter by typingnoyes

Why this isn't one component with a searchable prop. A combobox announces itself to a screen reader as an editable field, and opens the keyboard on mobile. For a three-option list both are wrong: there is nothing to type, and a keyboard covering half the screen to pick "Ascending" is a regression. Keeping the two separate lets each be correct for its own case, and sharing the appearance means the choice costs nothing visually.

If you find yourself adding a filter input above a Select, you wanted a Combobox. If more than one value can be chosen, neither applies — reach for MultiSelect.

Local vs. server filtering

Combobox has exactly one rule here, and it keys off whether you pass onSearch:

  • No onSearch — the component filters options locally, case-insensitively, on the label. Good for a fixed list you've already loaded.
  • With onSearch — the component does not filter. It debounces the query out to you, and renders whatever options you hand back. Filtering locally as well would hide rows the server deliberately returned, which is the classic "why is my search missing results" bug.

The owner does the fetching. That keeps the component transport-agnostic: it has no idea whether options came from an API, a cache, or a constant.

const [options, setOptions] = useState<ComboboxOption[]>([]);
const [loading, setLoading] = useState(false);

<Combobox
  label="Service Name"
  options={options}
  value={value}
  onChange={setValue}
  onSearch={async (q) => {
    setLoading(true);
    const res = await fetch(`/api/price/services?serviceType=${type}&q=${encodeURIComponent(q)}`);
    const page = await res.json();
    setOptions(page.items.map((s) => ({ value: s.serviceId, label: s.name })));
    setLoading(false);
  }}
  loading={loading}
/>;

States

A Combobox with nothing to show is in one of four states, and conflating any two of them is the most common mistake with a fetched selector:

ConditionReported byRendered message
The request is in flightloadingloadingMessage — "Searching…" by default
The source returned nothingoptionsNoun"No service types were returned from the server."
The query matched nothingemptyMessage"No matches." by default
The request failederrorthe message given, as a role="alert"

loading suppresses both empty messages, so an in-progress search never flashes a claim about a response that has not arrived. error suppresses them too: an empty dropdown is not an acceptable rendering of a failed request. A user who lacks a role, or who hits a 502, must be told that — not shown a selector that silently has nothing in it.

An empty source and an empty search are different facts

An upstream catalog answering 200 [] where it had answered with rows an hour earlier rendered as "No matches." — the same sentence a query that is too narrow produces. The screen was accurate and useless: nothing on it separated "your search is too narrow" from "the server sent back nothing at all".

The component tells them apart from the two lists it already holds, never from the message strings. options is what the owner supplied; visibleOptions is what survived local filtering.

  • options.length === 0 — nothing arrived. The message names the data through optionsNoun and reads "No <optionsNoun> were returned from the server."
  • options.length > 0 and nothing visible — the query narrowed a real list away, and emptyMessage reports it.

Where onSearch narrows upstream instead, options is the response to the query, so an empty one only reports on the source while no query has been typed. Once one has, that same empty list means the search matched nothing and emptyMessage is the honest reading.

An unfinished first load claims neither. A field that reports loading at all has to be seen finishing a load, or producing options, before the empty-source sentence is allowed — otherwise an owner that starts its request inside an effect renders one frame with nothing loading and nothing loaded, and that frame would carry a false report of an empty upstream.

Naming the data

optionsNoun takes a plural noun, not a sentence — "service types", "price seasons", "assignments". The sentence is the part that must not drift between fields, and a field supplying its own would be free to omit the one word that makes the message worth reading.

<Combobox
  label="Service Type"
  optionsNoun="service types"
  emptyMessage="No service types match that search."
  options={serviceTypes}
  value={value}
  onChange={setValue}
/>

It defaults to "options", so a field that has not supplied one reports "No options were returned from the server." — vaguer than it should be, but still the right fact.

The same noun names the paging control. A field with hasMore renders a button whose visible text is the bare "Load more" and whose accessible name is "Load more service types", so the button read out of context still says what it loads. It falls back the same way, to "Load more options". One noun serves both, because both describe the one collection the field holds — a field that named its empty result one way and its paging control another would be describing two collections.

Omit it only where there is no source to report on. A field whose options are built in the browser from a constant — YearFilter in RMS, whose list is generated from a fixed year range — cannot reach the message at all, because its list is never empty, and has no further page to ask for.

Disambiguating options that share a label

ComboboxOption.description renders a second line under the label. Use it when two options can legitimately present the same display name and the user would otherwise have to guess:

options={[
  { value: "Rail__c", label: "Rail", description: "Record type for train legs (Custom)" },
  { value: "KaptioTravel__Rail", label: "Rail", description: "Kaptio built-in record type" },
]}

Accessibility

  • The input carries role="combobox", aria-expanded, aria-controls and aria-autocomplete="list".
  • Keyboard: ↓/↑ move the active option (wrapping at both ends), Home/End jump to the first/last, Enter selects, Escape closes without selecting.
  • Always pass label. It is what names the input for assistive technology; a placeholder is not a label and disappears the moment the user types.

Do / Don't

  • Do pass loading while a search is in flight, so the empty message doesn't flash.
  • Do pass optionsNoun on every field whose options come from somewhere else, so an empty response names what was missing and the paging control names what it loads.
  • Do surface a lookup failure through error rather than rendering an empty list.
  • Don't write "no results" into emptyMessage and rely on it for both empty cases — that is the conflation optionsNoun exists to undo.
  • Don't put an "Any …" row in options unconditionally. A field carrying one is never empty, so a lookup that returned no rows presents as a working filter offering a single choice.
  • Don't filter options yourself when you've also passed onSearch — you'll drop server results.
  • Don't use it for multi-select; reach for MultiSelect.

On this page