Skip to content

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
  • Argentina
  • Bolivia
  • Brazil
  • Chile
  • Colombia
  • Ecuador
  • Perú
  • Uruguay

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.

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.

Change the props and copy the code.

  • Argentina
  • Bolivia
  • Brazil
  • Chile
  • Colombia
  • Ecuador
  • Perú
  • Uruguay

Where you are billed.

variant
color
size
shape
label
content
state
Generated code
TSX
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"
/>

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.

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.

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.

    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"
    />

    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' },
    ];

    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.

    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.

    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.

    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.

    • Argentina
    • Chile
    • Perú
    • Uruguay

    Where you are billed.

    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’s shouldFocusError calls focus() 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> a Form renders 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 a Form, and under noRhf, nothing is rendered and the field posts nothing at all. A ComboBox in a hand-written <form> is a React-owned value; use Select when the markup has to post by itself.
    • A cleared field posts "". The payload is always a string, matching the empty placeholder option a Select posts. onValueChange still reports null, 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.

    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.
    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 in states.css does 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 your onBlur does not fire and the caret stays where it was.
    • An IME keeps its keys. While a composition is open (Japanese, Chinese, Korean), Enter, Escape and the arrows belong to the input method: the Enter that picks a candidate neither commits a row nor throws the text away. The field reads isComposing, and the keyCode 229 that Safari sends once it has already ended the composition.
    • aria-activedescendant is 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 the role="listbox" would be a child the role forbids.
    • The surface is not portalled. With popover the element stays in the React tree, so a [data-theme] on an ancestor still resolves — the token bug a document.body portal 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 the ScrollKeepsOpen story is the gate.
    • A click in the field keeps the list and the query. The list is popover="manual", not auto: 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, because position: absolute is not a fallback for an element already in the top layer. The caveat on the fallback path is the usual one: an ancestor with overflow: hidden can clip the list.
    • Windows high contrast repaints the surface with system colours — Canvas behind a CanvasText border — and gives the active row Highlight / HighlightText. The trigger’s arrow is a gradient, which forced colors would erase, so it opts out and draws in CanvasText. The clear button’s cross and the chosen row’s check are painted backgrounds, which that mode would turn into Canvas, so both keep the system text colour of the button or row they sit in.

    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