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:
Select | Combobox | |
|---|---|---|
| Use for | short, fixed, known-at-build-time sets — a status, a sort direction, a currency | long lists, or option sets fetched at runtime |
| Renders | a button and a listbox | an ARIA 1.2 combobox: an editable text input and a popup listbox |
| Filter by typing | no | yes |
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 filtersoptionslocally, 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 whateveroptionsyou 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:
| Condition | Reported by | Rendered message |
|---|---|---|
| The request is in flight | loading | loadingMessage — "Searching…" by default |
| The source returned nothing | optionsNoun | "No service types were returned from the server." |
| The query matched nothing | emptyMessage | "No matches." by default |
| The request failed | error | the 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 throughoptionsNounand reads "No <optionsNoun> were returned from the server."options.length > 0and nothing visible — the query narrowed a real list away, andemptyMessagereports 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-controlsandaria-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; aplaceholderis not a label and disappears the moment the user types.
Do / Don't
- Do pass
loadingwhile a search is in flight, so the empty message doesn't flash. - Do pass
optionsNounon 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
errorrather than rendering an empty list. - Don't write "no results" into
emptyMessageand rely on it for both empty cases — that is the conflationoptionsNounexists to undo. - Don't put an "Any …" row in
optionsunconditionally. 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
optionsyourself when you've also passedonSearch— you'll drop server results. - Don't use it for multi-select; reach for
MultiSelect.