Text input that filters a list of options as you type. Supports multi-select tags, async loading and long, virtualized lists.
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 comboboxCopies 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.jsonWorks, 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
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
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
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.
| Prop | Type | Default |
|---|---|---|
labelrequiredVisible label. | string | none |
descriptionHelp text under the field. | string | none |
errorMessageShown when the field is invalid. | string | none |
placeholderHint shown in the empty input. | string | none |
sizeField height and font size. | "sm" | "md" | "lg" | "md" |
selectionModemultiple shows the chosen options as removable tags. | "single" | "multiple" | "single" |
valueControlled selection: one key (or null) in single mode, an array of keys in multiple mode. | Key | null | readonly Key[] | none |
allowsCustomValueLets the user keep text that doesn't match an option (single mode). | boolean | false |
menuTriggerWhat opens the list: typing, focusing, or only the button and arrow keys. | "input" | "focus" | "manual" | "input" |
isLoadingShows a spinner in the field, e.g. while an async search runs. | boolean | false |
virtualizedRenders only visible options. Turn on for lists of more than a few hundred items. | boolean | false |
emptyMessageShown when nothing matches the input. | string | "No results" |
isDisabledPrevents interaction and dims the field. | boolean | false |
isInvalidMarks the field invalid and shows errorMessage. | boolean | false |
isRequiredRequires a selection for form submission. | boolean | false |
onLoadMoreCalled when the user scrolls near the end of the list, for infinite loading. | () => void | none |
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