ComboBox
A text field that filters a list and commits one of its rows. Closed, it is an Input — the same four variants, the same eight roles, the same size ladder and the same label wrapper. Open, it is a floating surface of ours rather than the platform's, which is what buys the filtering and costs the native keyboard.
- Stable
- React 19
- APG combobox
- Shares field.css with Input
Where you are billed.
const COUNTRIES = [ { value: 'ar', label: 'Argentina' }, { value: 'cl', label: 'Chile' }, { value: 'pe', label: 'Perú' },];
<ComboBox options={COUNTRIES} label="Country" placeholder="Search countries" emptyLabel="No countries match" loadingLabel="Searching…" clearLabel="Clear country" listLabel="Countries"/>options is required and is never given a default: options = [] in a destructure allocates a
new array on every render, and that one line is what kept the previous implementation
re-arming its debounce forever.
Keep the array out of the render body. Module scope, useState, a query cache — anywhere
but inline. The component is built so that an inline array cannot corrupt what you are typing,
but it still rebuilds the option map on every render for nothing.
The four labels are required
Section titled “The four labels are required”emptyLabel, loadingLabel, clearLabel and listLabel are required in the type, not
optional strings with English defaults.
The reason is the default configuration. clearable is true, so out of the box the component
renders a button whose only accessible name is clearLabel, and a role="listbox" whose only
name is listLabel. Optional-and-undefaulted would mean the default configuration fails an
accessibility audit; defaulted-in-English would mean a Spanish product ships an English screen
reader announcement nobody notices.
Playground
Section titled “Playground”Change the props and copy the code.
Where you are billed.
Generated code
const COUNTRIES = [
{ value: 'ar', label: 'Argentina' },
{ value: 'cl', label: 'Chile' },
{ value: 'pe', label: 'Perú' },
];
<ComboBox
options={COUNTRIES}
placeholder="Search countries"
label="Country"
helper="Where you are billed."
emptyLabel="No countries match"
loadingLabel="Searching…"
clearLabel="Clear country"
listLabel="Countries"
/>Appearance
Section titled “Appearance”The four variants, the eight
roles, the five size steps and the full-width box are Input’s, from
the same field.css. So is shape, narrowed to rounded and rectangle — the same cut
Select makes, because a max radius curves into the edge the chevron sits
against.
What the role colour reaches beyond the border: the focus ring, the fill behind the highlighted row, and the check on the chosen one.
The open list follows menu conventions rather than list conventions. The highlighted row is a
solid fill of the role colour with that role’s -content on top — the pair every theme has
to declare, so the contrast is guaranteed rather than measured. The check on the chosen row
leads the row instead of trailing it, and keeps its box while unselected, so every label starts
on the same column. A row that is too long is truncated with an ellipsis, never wrapped, and
the panel is never wider than the field it belongs to.
The surface itself is opaque and flat: what tells the list apart from the page behind it is its own hairline border, not a shadow. It was raised by a lit edge and a drop shadow until 2026-08-29, and translucency was never on the table — a backdrop blur re-rasterises whatever moves behind the panel, where a border paints once. Either could come back as a separate decision for the whole family of floating surfaces; neither is one this component makes.
The chevron is the same conic-gradient triangle Select draws, and it rotates
180 degrees while the list is open — a disclosure control’s open affordance. Under
prefers-reduced-motion the chevron turns at once and the list fades in place in 160ms, with no
lift and no scale.
Filtering
Section titled “Filtering”Typing filters. The default filter folds the query and the labels through NFD and strips
diacritics, so peru matches Perú and jose matches José. The folding happens once per
options change, never once per keystroke.
filter |
What happens |
|---|---|
| omitted | The built-in folded, case-insensitive includes over the label. |
| a function | filter(option, query) per row, with the raw query. |
null |
Nothing is filtered — “I already did it”, the mode remote options use. |
Opening with the chevron always shows the whole list, even right after a filter narrowed it. That is a flag the component keeps for you: it tells “opened by typing” from “opened by the button”, and it is the fix for reopening a list to the one leftover row.
Remote options
Section titled “Remote options”onSearch is called with the query, debounced by searchDebounce (300 ms). The debounce
affects onSearch only — the built-in filter is synchronous and needs no delay.
Nothing is requested until you type.
onSearch calls: 0
Code
import { useEffect, useRef, useState } from "react";import type { ComboBoxOption } from "wayra-ui";import { ComboBox } from "wayra-ui";
const CATALOG: ComboBoxOption[] = [ { value: "ar", label: "Argentina" }, { value: "bo", label: "Bolivia" }, { value: "br", label: "Brazil" }, { value: "cl", label: "Chile" }, { value: "co", label: "Colombia" }, { value: "ec", label: "Ecuador" }, { value: "pe", label: "Perú" }, { value: "py", label: "Paraguay" }, { value: "uy", label: "Uruguay" },];
const EMPTY: ComboBoxOption[] = [];
// Stands in for the network. A real one would be a fetch with an abort signal.function search(query: string): Promise<ComboBoxOption[]> { const folded = query.trim().toLowerCase(); return new Promise((resolve) => { setTimeout(() => { resolve( folded === "" ? EMPTY : CATALOG.filter((option) => (option.label ?? "").toLowerCase().includes(folded), ), ); }, 400); });}
export function AsyncDemo() { const [options, setOptions] = useState<ComboBoxOption[]>(EMPTY); const [loading, setLoading] = useState(false); const [calls, setCalls] = useState(0); const requestRef = useRef(0);
// Cleans up after the last request, so a slow answer cannot overwrite a // newer one. useEffect( () => () => { requestRef.current += 1; }, [], );
function handleSearch(query: string) { requestRef.current += 1; const ticket = requestRef.current; setCalls((count) => count + 1); setLoading(true); void search(query).then((rows) => { if (ticket !== requestRef.current) { return; } setOptions(rows); setLoading(false); }); }
return ( <div className="hu-demo-col" style={{ paddingBottom: "14rem" }}> <ComboBox options={options} // The rows already came back narrowed, so filtering them again would // hide matches the server meant to show. filter={null} onSearch={handleSearch} loading={loading} label="Country" placeholder="Type to search" helper="Nothing is requested until you type." emptyLabel="No countries match" loadingLabel="Searching…" clearLabel="Clear country" listLabel="Countries" /> <p className="hu-readout">onSearch calls: {calls}</p> </div> );}A mounted, untouched ComboBox makes zero onSearch calls and arms zero timers. The counter
in that demo is there to prove it: it stays at 0 until you type. The effect’s dependencies are
primitives and its resting query is null, so an inline options array, an inline onSearch
or an inline accessor cannot re-arm anything.
Pair onSearch with filter={null}: the rows already came back narrowed, and filtering them
again locally hides matches the server meant to show.
The results do not have to contain the chosen row. The field remembers the last label it saw for
the value, so a search that drops the row leaves the query alone, and closing the list still
puts the label back. Only a value whose row has never been in options shows the raw value.
The value model
Section titled “The value model”value and inputValue are two different things, and merging them is the single mistake
this component exists to avoid. value is what the form posts and what decides aria-selected;
inputValue is the text in the field — the query while you type, the label once you commit.
Precedence is total and written down:
| Condition | Who owns the value |
|---|---|
inside a Form, with a name |
react-hook-form. The field shows the store and posts the store; a value prop is ignored. defaultValue seeds that key once on mount, unless the form already holds a non-empty value for it. |
otherwise, value !== undefined |
You. The component is controlled. |
| otherwise | The component’s own state, seeded once by defaultValue. |
Choose name or value, not both. Inside a Form the store wins, so what the field shows is
always what the form sends. To control a form field from outside, call form.setValue(name, …):
the field follows that, a reset() and every other write to the store. noRhf takes the field
out of the form, and then value controls it again.
Because that chain is written as a chain and not as value ?? rhf ?? default, value={null}
really clears the field. Under the old shape a null was indistinguishable from “not passed”,
which produced a controlled combobox that could never be emptied.
inputValue can be controlled too. The component cannot move a text it does not own, so when the
value changes under it — a reset(), a setValue, a new value — it calls onInputChange with
the new label and leaves the rest to you. A mount and a keystroke never trigger that call.
getOptionValue must return a string. That is what keeps the payload identical to
Select’s, so a list that outgrows a native <select> can be swapped
without touching the backend. It is also a change from the previous implementation, where the
accessor returned unknown and a numeric value travelled as a number.
The default accessors read value and label, so rows of any other shape must pass
getOptionValue and getOptionLabel, and the type says so: options={people} without them
does not compile. A row that extends ComboBoxOption keeps the defaults.
<ComboBox options={people} getOptionValue={(person) => person.id} getOptionLabel={(person) => person.name} label="Assignee" emptyLabel="Nobody matches" loadingLabel="Searching…" clearLabel="Clear assignee" listLabel="People"/>Groups
Section titled “Groups”getOptionGroup is the <optgroup> parity: a heading is emitted whenever the value changes
from one row to the next, so ordering the array is what orders the sections.
Each section is the APG grouped listbox: its rows sit in a role="group" that the heading names
through aria-labelledby, so a screen reader announces the group as the highlight enters it. A
<ul> admits only <li> children, so the group is a ul.wayra-combobox-section inside a bare
li role="none", and the heading is its first row, li.wayra-combobox-group with
role="presentation". Rows with no group stay directly in the listbox.
const COUNTRIES = [ { value: 'ar', label: 'Argentina', group: 'South America' }, { value: 'cl', label: 'Chile', group: 'South America' }, { value: 'mx', label: 'Mexico', group: 'North America' },];What’s not included
Section titled “What’s not included”No multiple, no free text — yet
Section titled “No multiple, no free text — yet”Both names are reserved, and neither ships as a dead prop. Multi-select with chips needs an accessibility story ARIA’s authoring practices do not describe for a popup listbox, and free text changes what the form posts, so both wait for a phase of their own rather than arriving half-built.
No virtualisation
Section titled “No virtualisation”Room is left for it — the option ids survive filtering, and aria-setsize/aria-posinset have
somewhere to go — but the first answer to a long list is fewer nodes, not a windowing library:
the check on the chosen row is a pseudo-element, so a row is one node instead of three or four.
Past a few thousand options, filter on the server with onSearch.
No hand-written markup parity
Section titled “No hand-written markup parity”Every other control in this library works from class + data-* alone, on markup you wrote
yourself, with no React. A combobox cannot. role, aria-expanded, aria-activedescendant
and the anchor name are JavaScript state. The closed field is still yours to reproduce — it is
the markup below — but the open behaviour is not. That exemption belongs to the whole family of
floating surfaces, not to this component alone.
<div class="wayra-label" data-placement="top" data-size="md"> <label class="wayra-label-text" for="country">Country</label> <div class="wayra-combobox"> <input class="wayra-combobox-input" id="country" role="combobox" aria-expanded="false" aria-controls="country-list" aria-autocomplete="list" aria-describedby="country-hint" value="Chile" readonly> <button class="wayra-combobox-trigger" type="button" tabindex="-1" aria-label="Countries"></button> <button class="wayra-combobox-clear" type="button" tabindex="-1" aria-label="Clear country"></button> <ul class="wayra-combobox-list wayra-overlay" id="country-list" role="listbox" aria-label="Countries"></ul> <p class="wayra-combobox-status" role="status"></p> </div> <p class="wayra-label-hint" id="country-hint">Where you are billed.</p></div>readonly is doing real work there: nothing is listening, so a field you could type into would
be a field that silently ignores you. Reproduce the closed state only — a placeholder before
hydration, or a printed form — and hand the open one to React.
That is also why the example at the top of this page is a mounted component and not a markup string, the one hero on this site that is: it opens, filters and commits, because it has to.
Label, helper, and error
Section titled “Label, helper, and error”The wrapper is the one Input documents, including a
floating label: the inner input takes the same one-space placeholder trick, so
:placeholder-shown still decides “empty”.
error takes the hint slot over helper, renders role="alert", and sets aria-invalid on
the inner input — from which a :has() mirror moves the wrapper’s border and focus ring to the
error colour. There is no :user-invalid arm: the inner input carries no HTML constraints, so
the JavaScript state is the only one that exists.
react-hook-form is built in, by a different route than Input’s. A ComboBox has no single
native control holding the value, so it registers a hidden anchor instead: that input carries
name, each commit is written as a change on that anchor — the path a native input takes, so
the form’s mode decides when it validates — and reads come back through
useWatch({ control, name, exact: true }). Focus leaving the whole control is its blur: it
marks the field touched and validates under onBlur and onTouched, while a click in the list
is not a leave.
Code
import { useState } from "react";import { useForm } from "react-hook-form";import { Button, ComboBox, Form } from "wayra-ui";
// Module scope: an inline array would be a new identity on every render.const COUNTRIES = [ { value: "ar", label: "Argentina" }, { value: "cl", label: "Chile" }, { value: "pe", label: "Perú" }, { value: "uy", label: "Uruguay" },];
type Values = { country: string };
export function FormDemo() { const form = useForm<Values>({ defaultValues: { country: "" } }); const [submitted, setSubmitted] = useState<string>();
return ( // Strategy (b): the value never lives in one native control, so ComboBox // writes it with setValue and registers a hidden anchor that carries // `name`. Nothing to configure — `name` is the whole wiring. <Form form={form} className="hu-demo-col" onSubmit={(values) => setSubmitted(values.country)} > <ComboBox options={COUNTRIES} name="country" label="Country" placeholder="Search countries" helper="Where you are billed." emptyLabel="No countries match" loadingLabel="Searching…" clearLabel="Clear country" listLabel="Countries" /> <Button type="submit" width="block"> Save </Button> {submitted && <p className="hu-readout">submitted: {submitted || "—"}</p>} </Form> );}Three details of that anchor are load-bearing:
- It is a real text input, not
type="hidden". react-hook-form’sshouldFocusErrorcallsfocus()on the registered element, and a hidden input cannot take focus — the feature dies silently. This one is visually hidden and out of the tab order instead, and forwards its focus to the input you can see. - It owns
name; the visible input never has one. So a native submit of the<form>aFormrenders posts the canonical value, never the search text — the legacy component posted the query. The anchor exists only where there is something to register: outside aForm, and undernoRhf, nothing is rendered and the field posts nothing at all. A ComboBox in a hand-written<form>is a React-owned value; useSelectwhen the markup has to post by itself. - A cleared field posts
"". The payload is always a string, matching the empty placeholder option aSelectposts.onValueChangestill reportsnull, because that is the value model, not the wire format.
noRhf turns the whole thing off for one instance: no anchor is rendered, so nothing registers.
API reference
Section titled “API reference”ComboBoxProps<T = ComboBoxOption> extends ComponentProps<"input"> minus value,
defaultValue, onChange, size, color, width, height, name and children, which are
redefined below — except width and height, which are dropped outright with the field’s
fixed width, as on Input.
| Prop | Type | Default | Description |
|---|---|---|---|
options |
readonly T[] |
— required | The rows. Never defaulted; keep the array out of the render body. |
getOptionValue |
(o: T) => string |
o.value |
Must return a string. Required for a row that is not a ComboBoxOption. |
getOptionLabel |
(o: T) => string |
o.label ?? o.value |
What is read and what the filter matches. Required for a row that is not a ComboBoxOption. |
getOptionDisabled |
(o: T) => boolean |
o.disabled ?? false |
Readable, walked past, never chosen. |
getOptionGroup |
(o: T) => string | undefined |
o.group |
The <optgroup> parity. |
renderOption |
(o, {active, selected, disabled}) => ReactNode |
— | Custom row content. |
value |
string | null |
— | Controlled. null clears. Ignored inside a Form with a name: the store wins. |
onValueChange |
(v: string | null, o: T | null) => void |
— | Fired on every commit. |
inputValue / onInputChange |
string / (t: string) => void |
— | The displayed text, separate state. Controlled, it hears of a reset or a new value through onInputChange. |
defaultValue |
string | null |
— | Seeds the value once, on mount — the component’s own state outside a Form, the form’s value for name inside one. |
filter |
((o: T, q: string) => boolean) | null |
folded includes |
null means “already filtered”. |
onSearch |
(q: string) => void |
— | Remote options. Zero calls at rest. |
searchDebounce |
number |
300 |
Affects onSearch only. |
loading |
boolean |
false |
aria-busy plus loadingLabel in the surface. |
emptyLabel |
string |
— required | Shown and announced when nothing matches. |
loadingLabel |
string |
— required | Shown and announced while loading. |
clearLabel |
string |
— required | Accessible name of the clear button. |
listLabel |
string |
— required | Accessible name of the listbox and the open button. |
clearable |
boolean |
true |
Offers the clear button once a value is chosen. |
maxHeight |
string |
min(18rem, 40vh) |
Writes --wayra-combobox-max-height. |
variant |
"outline" | "filled" | "soft" | "ghost" |
"outline" |
As on Input. |
color |
WayraColor |
"primary" |
Edge, ring, highlight and check. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
The shared ladder. |
shape |
"rounded" | "rectangle" |
"rounded" |
No pill. |
readOnly |
boolean |
false |
Shown, focusable, copyable; the list never opens. |
noRhf |
boolean |
false |
Opts this instance out of the surrounding Form. |
label, labelPlacement, helper, error, id, name, unstyled, className, ref |
The field block, identical to Input. ref reaches the visible input. |
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
| Printable keys | Typing. This is the half that differs from a <select>, where they are type-ahead. |
↓ / ↑ |
Opens the list when closed, with the chosen row highlighted, so ↓ then Enter keeps the value. Open, moves the highlight. Disabled rows are skipped; both ends wrap. |
Alt+↓ |
Opens without moving the highlight. |
Alt+↑ |
Closes without committing. |
Enter |
Commits the highlighted row while the list is open. Closed, it belongs to the form — the field submits. |
Escape |
Two stages: closes and restores the committed label, then clears. |
Tab |
Leaves without committing. The field is a single tab stop: the chevron and the clear button are tabindex="-1". |
Home / End |
Not intercepted. They belong to the caret. |
- DOM focus never leaves the input. The highlight travels through
aria-activedescendant, which is why the options are styled from a[data-active]attribute and not from:focus— the single focus-ring rule instates.cssdoes not reach them, one of several parts that ring or highlight themselves. Pressing the chevron or the clear button does not move focus either, so youronBlurdoes not fire and the caret stays where it was. - An IME keeps its keys. While a composition is open (Japanese, Chinese, Korean),
Enter,Escapeand the arrows belong to the input method: theEnterthat picks a candidate neither commits a row nor throws the text away. The field readsisComposing, and thekeyCode229 that Safari sends once it has already ended the composition. aria-activedescendantis derived, never stored as an index. It is absent when the list is closed and absent when the highlighted row has been filtered out, so it cannot point at an id that is not on the page.- Empty and loading are announced from a sibling
role="status", outside the listbox; what you see is the list’s own::before. Text inside therole="listbox"would be a child the role forbids. - The surface is not portalled. With
popoverthe element stays in the React tree, so a[data-theme]on an ancestor still resolves — the token bug adocument.bodyportal creates. - Scrolling does not dismiss it. The list closes when focus leaves the field and on
Escape, never on a scroll, and anchor positioning keeps the panel under the field without a scroll listener. A native<select>loses its system popup to a page scroll in Chrome; this does not, and theScrollKeepsOpenstory is the gate. - A click in the field keeps the list and the query. The list is
popover="manual", notauto: the input sits outside the list, and light dismiss would read a click that moves the caret as a click outside, close the list and throw the query away. The in-flow path never had light dismiss, so both tiers now close the same way. - Browser tier. Below Chrome 128 / Safari 26 / Firefox 147 there is no CSS anchor
positioning with
position-try-fallbacks(the spelling the probe asks for), and the component falls back to an in-flow absolutely positioned list. Popover and anchor positioning are probed together, becauseposition: absoluteis not a fallback for an element already in the top layer. The caveat on the fallback path is the usual one: an ancestor withoverflow: hiddencan clip the list. - Windows high contrast repaints the surface with system colours —
Canvasbehind aCanvasTextborder — and gives the active rowHighlight/HighlightText. The trigger’s arrow is a gradient, which forced colors would erase, so it opts out and draws inCanvasText. The clear button’s cross and the chosen row’s check are painted backgrounds, which that mode would turn intoCanvas, so both keep the system text colour of the button or row they sit in.
Do and don’t
Section titled “Do and don’t”Do
Filtering earns its keep when the list is long enough that scanning it is the slow part.
Don't
Six options do not need a text field. A native wins here on touch, in Voice Control and in high contrast.