Skip to content

Combobox

Text input that filters a list of options as you type. Supports multi-select tags, async loading and long, virtualized lists.

Inputv0.1.2experimentalWCAG 2.2 AAView spec
import { Combobox, ComboboxItem } from "@rdloom/react";

const countries = [
  { id: "in", name: "India" },
  { id: "jp", name: "Japan" },
  { id: "de", name: "Germany" },
  { id: "br", name: "Brazil" },
  { id: "ca", name: "Canada" },
  { id: "ke", name: "Kenya" },
];

export default function ComboboxBasicExample() {
  return (
    <Combobox className="w-64" label="Country" placeholder="Search countries" defaultItems={countries}>
      {(c) => <ComboboxItem id={c.id}>{c.name}</ComboboxItem>}
    </Combobox>
  );
}

Installation

npx rdloom add combobox

Copies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.

Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/combobox.json

Works, but without upgrade tracking.

Usage

import { Combobox, ComboboxItem } from "@rdloom/react";

<Combobox className="w-64" label="Country" placeholder="Search countries" defaultItems={countries}>
  {(c) => <ComboboxItem id={c.id}>{c.name}</ComboboxItem>}
</Combobox>

Multiple

IndiaJapan
import { Combobox, ComboboxItem } from "@rdloom/react";

const countries = [
  { id: "in", name: "India" },
  { id: "jp", name: "Japan" },
  { id: "de", name: "Germany" },
  { id: "br", name: "Brazil" },
  { id: "ca", name: "Canada" },
  { id: "ke", name: "Kenya" },
];

export default function ComboboxMultipleExample() {
  return (
    <Combobox
      className="w-72"
      label="Markets"
      selectionMode="multiple"
      placeholder="Add markets"
      defaultItems={countries}
      defaultValue={["in", "jp"]}
    >
      {(c) => <ComboboxItem id={c.id}>{c.name}</ComboboxItem>}
    </Combobox>
  );
}

Async

import { useAsyncList } from "react-aria-components";
import { Combobox, ComboboxItem } from "@rdloom/react";

interface User {
  id: string;
  name: string;
}

const directory: User[] = Array.from({ length: 200 }, (_, i) => ({ id: `u${i}`, name: `User ${i + 1}` }));

/** Stands in for your API: 20 results per page. */
async function searchUsers(query: string, cursor: number) {
  await new Promise((r) => setTimeout(r, 300));
  const matches = directory.filter((u) => u.name.toLowerCase().includes(query.toLowerCase()));
  const next = cursor + 20;
  return { items: matches.slice(cursor, next), next: next < matches.length ? next : undefined };
}

export default function ComboboxAsyncExample() {
  // useAsyncList handles filtering, paging and out-of-order responses.
  const users = useAsyncList<User, number>({
    async load({ filterText, cursor = 0 }) {
      const page = await searchUsers(filterText ?? "", cursor);
      return { items: page.items, cursor: page.next };
    },
  });
  return (
    <Combobox
      className="w-64"
      label="Assignee"
      placeholder="Find a user"
      items={users.items}
      inputValue={users.filterText}
      onInputChange={users.setFilterText}
      isLoading={users.isLoading}
      onLoadMore={users.loadMore}
      menuTrigger="focus"
    >
      {(u) => <ComboboxItem id={u.id}>{u.name}</ComboboxItem>}
    </Combobox>
  );
}

Custom value

Pick one or type your own
import { Combobox, ComboboxItem } from "@rdloom/react";

const tags = [
  { id: "bug", name: "bug" },
  { id: "feature", name: "feature" },
  { id: "docs", name: "docs" },
];

export default function ComboboxCustomValueExample() {
  return (
    <Combobox className="w-64" label="Tag" description="Pick one or type your own" allowsCustomValue defaultItems={tags}>
      {(t) => <ComboboxItem id={t.id}>{t.name}</ComboboxItem>}
    </Combobox>
  );
}

Virtualized

5,000 options
import { Combobox, ComboboxItem } from "@rdloom/react";

const products = Array.from({ length: 5000 }, (_, i) => ({ id: `p${i}`, name: `Product ${String(i + 1).padStart(4, "0")}` }));

export default function ComboboxVirtualizedExample() {
  return (
    <Combobox className="w-64" label="Product" description="5,000 options" virtualized defaultItems={products} placeholder="Search products">
      {(p) => <ComboboxItem id={p.id}>{p.name}</ComboboxItem>}
    </Combobox>
  );
}

API Reference

Defined by the spec. Components also accept the props of the React Aria component they wrap.

PropTypeDefault
labelrequired

Visible label.

stringnone
description

Help text under the field.

stringnone
errorMessage

Shown when the field is invalid.

stringnone
placeholder

Hint shown in the empty input.

stringnone
size

Field height and font size.

"sm" | "md" | "lg""md"
selectionMode

multiple shows the chosen options as removable tags.

"single" | "multiple""single"
value

Controlled selection: one key (or null) in single mode, an array of keys in multiple mode.

Key | null | readonly Key[]none
allowsCustomValue

Lets the user keep text that doesn't match an option (single mode).

booleanfalse
menuTrigger

What opens the list: typing, focusing, or only the button and arrow keys.

"input" | "focus" | "manual""input"
isLoading

Shows a spinner in the field, e.g. while an async search runs.

booleanfalse
virtualized

Renders only visible options. Turn on for lists of more than a few hundred items.

booleanfalse
emptyMessage

Shown when nothing matches the input.

string"No results"
isDisabled

Prevents interaction and dims the field.

booleanfalse
isInvalid

Marks the field invalid and shows errorMessage.

booleanfalse
isRequired

Requires a selection for form submission.

booleanfalse
onLoadMore

Called when the user scrolls near the end of the list, for infinite loading.

() => voidnone

Accessibility

Role combobox, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.

Keyboard

  • Typing filters the list
  • Down arrow opens the list; Up/Down arrows move through options
  • Enter selects, Esc closes and restores the input
  • Backspace in an empty input removes the last tag (multiple mode)

Screen readers announce

  • On focus: the label, combo box, and collapsed or expanded
  • Typing announces the number of results or the focused option
  • Moving through options announces each option and its position; in the 5,000-item list, positions like 4,999 of 5,000
  • No matches: No results is announced
  • Async: Loading is announced while results load
  • Multiple: each tag's button is announced as Remove <option>; removing one with Backspace makes its removal clear

What your code must do

  • Input is labelled and announces the number of results
  • Each tag has a remove button named 'Remove <option>'
  • Loading state is announced

Guidelines

Use it when

  • Choosing from a long or searchable list, like countries, users or products
  • Tagging with several values

Avoid it when

  • Fewer than about 7 options: use Select or RadioGroup

Don't

  • Async search without a loading state
  • Rendering thousands of options without virtualized
  • Passing isInvalid for required or format checks (even isInvalid={false}): any defined value takes over validity and hides the built-in errors. Use isRequired and validate; pass isInvalid only for errors the app finds itself, such as from the server

Design tokens

The semantic tokens this component uses. Change them once and every component follows; see Design tokens.

  • --rd-color-surface-default
  • --rd-color-surface-raised
  • --rd-color-surface-subtle
  • --rd-color-border-default
  • --rd-color-border-strong
  • --rd-color-text-default
  • --rd-color-text-muted
  • --rd-color-action-primary
  • --rd-color-feedback-danger
  • --rd-color-focus-ring
  • --rd-radius-control
  • --rd-radius-overlay