Skip to content

Combobox

  • Picking from a long list by typing: cities, people, products
  • Choosing several values, shown as chips: reviewers, labels
  • Options searched or loaded from a server as people type or scroll

Not for a short list people can scan — use a Select or a RadioGroup.

Choosing between choosing one option or a value:

ComponentUse it forFor example
RadioGroupUp to about five options, all visible at once, as a form valueShipping speed, plan size
SelectOne option from a longer list, in little space, with the system pickerCountry, time zone, sort order in a form
ComboboxA long list people search by typing, several picks, or options loaded from a serverAssign reviewers, search a city, add tags
ToggleGroupA few options shown as buttons that apply at once, not a form fieldText alignment, list or grid view
SliderA number in a range, where dragging is easier than typing an exact valueVolume, brightness, price range

Choosing between entering text:

ComponentUse it forFor example
InputOne line of text: a name, an email, a searchEmail address, search box
OTP inputA code of fixed length, typed or pasted one character per boxSMS code, two-factor code, PIN
TextareaSeveral lines of free text that may wrapComment, description, message
ComboboxTyping to find and pick from a list of known optionsChoose a city, add existing tags
FieldNot a control: wraps any of these to tie a label, a hint and an error to itAny form control with validation

Combobox is a text input that filters a list as you type. It follows the WAI-ARIA combobox pattern, and behaves as Base UI’s Combobox does. The list is a native popover, anchored to the field with CSS like Popover.

Use Select for a short list nobody needs to search.

Argentina
Australia
Brazil (unavailable)
Canada
Côte d’Ivoire
France
Germany
India
Japan
México
United Kingdom
United States
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
// Not shipping there yet: shown, but it can't be picked and the arrow keys skip it.
const unavailable = new Set(['Brazil']);
const countries = [
'Argentina',
'Australia',
'Brazil',
'Canada',
'Côte d’Ivoire',
'France',
'Germany',
'India',
'Japan',
'México',
'United Kingdom',
'United States',
];
export default function ComboboxBasics() {
return (
<div style={field}>
<label htmlFor="combobox-country">Country</label>
{/* Pre-selected: the input starts with its label, and the list marks it. */}
<Combobox.Root items={countries} defaultValue="India" name="country">
<Combobox.Input id="combobox-country" placeholder="Search countries…" />
<Combobox.Popup>
<Combobox.Empty>No countries found.</Combobox.Empty>
<Combobox.List>
{(country: string) => (
<Combobox.Item key={country} value={country} disabled={unavailable.has(country)}>
{country}
{unavailable.has(country) && ' (unavailable)'}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
);
}
import { Combobox } from '@arun-dev/ui';
<Combobox.Root items={items}>
<Combobox.Input />
<Combobox.Popup>
<Combobox.Status />
<Combobox.Empty />
<Combobox.List>{(item) => <Combobox.Item key={item} value={item} />}</Combobox.List>
</Combobox.Popup>
</Combobox.Root>;
PartRendersDoes
Roothidden inputs, with nameholds the items, the selection, the text and open; filters the items
Input<div> around an <input>the field: chips, the text, Clear and a chevron, and what the list lines up with
Popup<div>the box the list opens in: popover="manual", as wide as the field
List<div role="listbox">renders its function once per item the filter left
Item<div role="option">one option, with a check while selected
Group<div role="group">items under a label, as an <optgroup>; optional
GroupLabel<div>the group’s name, shown but not an option
Separator<div aria-hidden>a line between items or groups, as an <hr> in a <select>; optional
Empty<div>shown when nothing matches and nothing is loading
Status<div role="status">a live region for “Searching…” or a result count

Items are data, not children. Pass every option to the Root as items. The Root filters them, and List renders what is left. An item can be a string, or an object with a label. Otherwise, give itemToString for its label and itemToKey for its identity.

Focus stays in the input. The highlighted option is announced through aria-activedescendant.

KeyDoes
typingopens the list and filters it
↓ ↑opens the list at the selected or first item, then moves, wrapping
Alt+↓opens the list without highlighting
Home, Endthe first or last item, while open
Enterpicks the highlighted item
Esccloses the list, putting the text back
Tabcloses the list, and focus moves on
Backspace in an empty inputwith multiple, removes the last item
← at the start of the textwith multiple, moves into the chips: ← → between them, Backspace or Delete removes one
Book: Dune
Book: Atlas of Remote Islands
Book: Dune
Book: Sapiens
Pen: Ballpoint
Pen: Fountain
Pen: Gel (out of stock)
Notebook: A5 dotted
Selected: book-dune
import { useState } from 'react';
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '24rem' };
type Product = { id: string; label: string; outOfStock?: boolean };
const products: Product[] = [
{ id: 'book-atlas', label: 'Book: Atlas of Remote Islands' },
{ id: 'book-dune', label: 'Book: Dune' },
{ id: 'book-sapiens', label: 'Book: Sapiens' },
{ id: 'pen-ballpoint', label: 'Pen: Ballpoint' },
{ id: 'pen-fountain', label: 'Pen: Fountain' },
{ id: 'pen-gel', label: 'Pen: Gel', outOfStock: true },
{ id: 'notebook-a5', label: 'Notebook: A5 dotted' },
];
// Search "book", pick some; search "pen", pick some. Every pick stays selected.
export default function ComboboxMultiple() {
// Pre-selected: Dune starts as a chip. Out-of-stock items can't be picked.
const [value, setValue] = useState<Product[]>(() => products.filter((p) => p.id === 'book-dune'));
return (
<div style={field}>
<label htmlFor="combobox-products">Products</label>
<Combobox.Root
items={products}
itemToKey={productId}
multiple
value={value}
onValueChange={setValue}
name="products"
>
<Combobox.Input
id="combobox-products"
placeholder={value.length > 0 ? undefined : 'Search products…'}
/>
<Combobox.Popup>
<Combobox.Empty>No products found.</Combobox.Empty>
<Combobox.List>
{(product: Product) => (
<Combobox.Item key={product.id} value={product} disabled={product.outOfStock}>
{product.label}
{product.outOfStock && ' (out of stock)'}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
<small>Selected: {value.map((p) => p.id).join(', ') || 'none'}</small>
</div>
);
}
// Outside the component, so the Root sees the same function every render.
function productId(product: Product) {
return product.id;
}
InteractionSinglemultiple
Pick an itemthe input shows its label; the list closesadded as a chip; the text clears; the list closes
Pick a selected itemstays selectedremoved
Empty the inputthe selection clearsthe selection stays
Esc, or a press outsidecloses; the input goes back to the selected labelcloses; the text clears
Clear (×)clears the text and the selectionclears the text and every item

A selection survives a search that no longer returns it. value holds the items themselves, not their keys. So search “book” and pick, then search “pen” and pick: the books stay selected, keep their chips and labels, and are still submitted with the form.

Start with a selection through defaultValue, or value when controlled. A single combobox shows the label in the input, and multiple shows a chip per item. Both examples above start with one.

Atlas
Borealis (booked)
Cascade
Delta (booked)
Everest
Fjord
Atlas
Borealis (booked)
Cascade
Delta (booked)
Everest
Fjord
import { Combobox } from '@arun-dev/ui';
const stack = { display: 'grid', gap: 'var(--space-sm)', maxInlineSize: '24rem' };
const field = { display: 'grid', gap: 'var(--space-3xs)' };
// Booked rooms are listed but can't be picked, and the arrow keys skip them.
const rooms = [
{ id: 'atlas', label: 'Atlas', booked: false },
{ id: 'borealis', label: 'Borealis', booked: true },
{ id: 'cascade', label: 'Cascade', booked: false },
{ id: 'delta', label: 'Delta', booked: true },
{ id: 'everest', label: 'Everest', booked: false },
{ id: 'fjord', label: 'Fjord', booked: false },
];
type Room = (typeof rooms)[number];
// Nothing is selected to start with. `disabled` works the same in single and multiple select.
export default function ComboboxDisabled() {
return (
<div style={stack}>
<div style={field}>
<label htmlFor="combobox-disabled-room">Room</label>
<Combobox.Root items={rooms} itemToKey={roomId} name="room">
<Combobox.Input id="combobox-disabled-room" placeholder="Pick a room…" />
<Combobox.Popup>
<Combobox.List>{renderRoom}</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
<div style={field}>
<label htmlFor="combobox-disabled-rooms">Rooms</label>
<Combobox.Root items={rooms} itemToKey={roomId} multiple name="rooms">
<Combobox.Input id="combobox-disabled-rooms" placeholder="Pick rooms…" />
<Combobox.Popup>
<Combobox.List>{renderRoom}</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
</div>
);
}
function renderRoom(room: Room) {
return (
<Combobox.Item key={room.id} value={room} disabled={room.booked}>
{room.label}
{room.booked && ' (booked)'}
</Combobox.Item>
);
}
function roomId(room: Room) {
return room.id;
}

Mark an option disabled on its Item. It can’t be picked, and the arrow keys skip it. It is announced as unavailable through aria-disabled, and styled from data-disabled. This works the same in single and multiple select, as disabled on a native <option> does: it stops a pick only, and an item that is already selected can still be removed.

As with a native <select multiple>, every selected item can be removed, disabled or not. There is no locked item. If your app needs one to stay, such as a code owner among reviewers, control value and put it back in onValueChange:

<Combobox.Root
multiple
value={reviewers}
onValueChange={(next) => setReviewers([...owners, ...next.filter((r) => !owners.includes(r))])}
>

To fix a single selection, disable the whole field with disabled on the Root.

Africa
Egypt
Ghana
Kenya
Nigeria
Americas
Argentina
Brazil
Canada
México
United States
Asia
India
Japan
Singapore
South Korea
Europe
France
Germany
Spain
United Kingdom
Oceania (coming soon)
Australia
New Zealand
import { Fragment } from 'react';
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
// Groups are data: any object with an `items` array. The rest is yours to render.
type Region = { label: string; comingSoon?: boolean; items: string[] };
const regions: Region[] = [
{ label: 'Africa', items: ['Egypt', 'Ghana', 'Kenya', 'Nigeria'] },
{ label: 'Americas', items: ['Argentina', 'Brazil', 'Canada', 'México', 'United States'] },
{ label: 'Asia', items: ['India', 'Japan', 'Singapore', 'South Korea'] },
{ label: 'Europe', items: ['France', 'Germany', 'Spain', 'United Kingdom'] },
// Shown, but every country in it is disabled, as with <optgroup disabled>.
{ label: 'Oceania (coming soon)', comingSoon: true, items: ['Australia', 'New Zealand'] },
];
// Search "an": only the groups with a match stay, each holding only its matches.
export default function ComboboxGrouped() {
return (
<div style={field}>
<label htmlFor="combobox-grouped">Ship to</label>
<Combobox.Root items={regions} name="country">
<Combobox.Input id="combobox-grouped" placeholder="Search countries…" />
<Combobox.Popup>
<Combobox.Empty>No countries found.</Combobox.Empty>
<Combobox.List>
{(region: Region, index) => (
<Fragment key={region.label}>
{/* A line between regions, as an <hr> in a <select>. */}
{index > 0 && <Combobox.Separator />}
<Combobox.Group disabled={region.comingSoon}>
<Combobox.GroupLabel>{region.label}</Combobox.GroupLabel>
{region.items.map((country) => (
<Combobox.Item key={country} value={country}>
{country}
</Combobox.Item>
))}
</Combobox.Group>
</Fragment>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
);
}

Group items as a native <optgroup> does. Pass groups as items: each an object with an items array, plus whatever you render for it, such as a label. The List’s function then gets one group at a time. Render a Group with its GroupLabel and an Item per item:

<Combobox.Root items={regions}>
<Combobox.Input />
<Combobox.Popup>
<Combobox.Empty>No countries found.</Combobox.Empty>
<Combobox.List>
{(region: Region) => (
<Combobox.Group key={region.label} disabled={region.comingSoon}>
<Combobox.GroupLabel>{region.label}</Combobox.GroupLabel>
{region.items.map((country) => (
<Combobox.Item key={country} value={country}>
{country}
</Combobox.Item>
))}
</Combobox.Group>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
  • Search runs inside each group, and a group with no match is left out. region.items already holds only the matches.
  • The arrow keys move through every group as one list. They skip the labels and disabled items.
  • disabled on a Group disables every item in it, as <optgroup disabled> does.
  • The value is still an item, never a group. A group is any object with an items array, so an item type with its own items array can’t be listed ungrouped.
  • Groups don’t nest, as with <optgroup>.
  • Combobox.Separator draws a line between items or groups, as an <hr> in a <select>. It is visual only (aria-hidden), since a listbox may hold only options and groups. The example above puts one between each region.
bug
documentation
enhancement
good first issue
import { useState } from 'react';
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '24rem' };
type Label = { id: string; name: string; create?: boolean };
/** Case- and accent-insensitive, as the default filter compares. */
function fold(text: string) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();
}
// Type a label that isn't there — "perf" — and pick Create.
export default function ComboboxCreatable() {
const [labels, setLabels] = useState<Label[]>([
{ id: 'bug', name: 'bug' },
{ id: 'documentation', name: 'documentation' },
{ id: 'enhancement', name: 'enhancement' },
{ id: 'good-first-issue', name: 'good first issue' },
]);
const [value, setValue] = useState<Label[]>([]);
const [text, setText] = useState('');
const query = text.trim();
const exact = labels.some((label) => fold(label.name) === fold(query));
// The "Create" row is an item, labelled with the text itself.
const items =
query !== '' && !exact
? [...labels, { id: `create:${query}`, name: query, create: true }]
: labels;
function createLabel(name: string): Label {
const label = { id: fold(name).replace(/\s+/g, '-'), name };
setLabels((current) => [...current, label]);
return label;
}
return (
<div style={field}>
<label htmlFor="combobox-labels">Labels</label>
<Combobox.Root
items={items}
itemToString={labelName}
itemToKey={labelId}
multiple
value={value}
// Swap the "Create" item for the label it makes.
onValueChange={(next) =>
setValue(next.map((label) => (label.create ? createLabel(label.name) : label)))
}
onInputValueChange={setText}
name="labels"
>
<Combobox.Input id="combobox-labels" placeholder="Filter or create labels…" />
<Combobox.Popup>
<Combobox.List>
{(label: Label) => (
<Combobox.Item key={label.id} value={label}>
{label.create ? `Create new label "${label.name}"` : label.name}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
);
}
function labelName(label: Label) {
return label.name;
}
function labelId(label: Label) {
return label.id;
}

Offer Create “x” when the text matches nothing, as GitHub’s label picker does. It is a pattern, not a prop. The “Create” row is an ordinary item that you add to items:

  1. Track the text with onInputValueChange.
  2. While no item matches it exactly, ignoring case and accents, add a “Create” item. Give it the text as its label, so a pick puts the new name in the input, and render it as “Create …” in its Item.
  3. In onValueChange, swap the “Create” item for the real item you make, and add that to your list.
const exact = labels.some((l) => fold(l.name) === fold(text));
const items = text && !exact ? [...labels, { id: `create:${text}`, name: text, create: true }] : labels;
<Combobox.Root
items={items}
multiple
value={value}
onValueChange={(next) => setValue(next.map((l) => (l.create ? createLabel(l.name) : l)))}
onInputValueChange={setText}
>

The arrow keys and Enter reach the “Create” row like any other. Making the item is up to you: saving it, asking your server for an id, or opening a Dialog for a colour first.

By default, an item matches when its label contains the text, ignoring case and accents: “cote” finds “Côte d’Ivoire”. To search another way, pass filter. It receives the whole list, so it can rank as well as narrow.

filter?: (items: T[], query: string, itemToString: (item: T) => string) => T[]
Open file
Open recent
Save file
Save all
Close editor
Toggle sidebar
Format document
Find in files
Go to line
Go to symbol
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
const commands = [
'Open file',
'Open recent',
'Save file',
'Save all',
'Close editor',
'Toggle sidebar',
'Format document',
'Find in files',
'Go to line',
'Go to symbol',
];
/**
* A small fuzzy match: the query's letters appear in order, not necessarily together. Items
* whose letters sit closer together rank first — so "fd" puts "Find in files" above
* "Format document". Swap in a library such as match-sorter or Fuse.js the same way.
*/
function fuzzy(items: readonly string[], query: string, itemToString: (item: string) => string) {
const needle = query.toLowerCase().replace(/\s/g, '');
if (needle === '') return [...items];
const scored: { item: string; score: number }[] = [];
for (const item of items) {
const label = itemToString(item).toLowerCase();
let at = -1;
let gaps = 0;
for (const char of needle) {
const next = label.indexOf(char, at + 1);
if (next === -1) {
at = -2;
break;
}
if (at >= 0) gaps += next - at - 1;
at = next;
}
if (at !== -2) scored.push({ item, score: gaps });
}
return scored.sort((a, b) => a.score - b.score).map((s) => s.item);
}
export default function ComboboxFuzzy() {
return (
<div style={field}>
<label htmlFor="combobox-command">Command</label>
<Combobox.Root items={commands} filter={fuzzy}>
<Combobox.Input id="combobox-command" placeholder="Try “gts” or “fd”" />
<Combobox.Popup>
<Combobox.Empty>No matching commands.</Combobox.Empty>
<Combobox.List>
{(command: string) => (
<Combobox.Item key={command} value={command}>
{command}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
);
}

A library like match-sorter or Fuse.js plugs in the same way. To narrow with the default first, import it as defaultFilter from @arun-dev/headless/combobox.

Set filter={null} to turn local filtering off. Fetch in onInputValueChange, pass the results as items, and set loading while a request is in flight. Selected items do not have to be in items.

Start typing to search people.
import { useEffect, useState } from 'react';
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '24rem' };
type User = { id: number; name: string };
const people = [
'Aarav Shah',
'Amelia Brown',
'Chen Wei',
'Diego Alvarez',
'Fatima Khan',
'Hana Sato',
'Isabella Rossi',
'Kwame Mensah',
'Lucas Martin',
'Maya Patel',
'Noah Fischer',
'Olivia Smith',
'Priya Nair',
'Sofia García',
'Yuki Tanaka',
'Zara Ahmed',
].map((name, id) => ({ id, name }));
/** Stands in for a request to your API. */
function searchUsers(query: string, signal: AbortSignal): Promise<User[]> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const q = query.toLowerCase();
resolve(people.filter((p) => p.name.toLowerCase().includes(q)));
}, 400);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
export default function ComboboxAsync() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState<User[]>([]);
// Loading starts as the query changes, in the handler rather than an effect: an effect that
// sets state renders twice.
function search(text: string) {
// The same text starts no new request, so it must not start loading either.
if (text === query) return;
setQuery(text);
const empty = text.trim() === '';
setLoading(!empty);
if (empty) setResults([]);
}
// Debounce the query, cancel the request it replaces.
useEffect(() => {
if (query.trim() === '') return;
const controller = new AbortController();
const timer = setTimeout(() => {
searchUsers(query, controller.signal)
.then((users) => {
setResults(users);
setLoading(false);
})
.catch(() => {});
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
const status = loading
? 'Searching…'
: query.trim() === ''
? 'Start typing to search people.'
: `${results.length} result${results.length === 1 ? '' : 's'}`;
return (
<div style={field}>
<label htmlFor="combobox-reviewers">Reviewers</label>
<Combobox.Root
items={results}
// The server already filtered: show its results as they are.
filter={null}
itemToString={userName}
itemToKey={userId}
multiple
value={value}
onValueChange={setValue}
onInputValueChange={search}
loading={loading}
>
<Combobox.Input
id="combobox-reviewers"
placeholder={value.length > 0 ? undefined : 'Search people…'}
/>
<Combobox.Popup>
<Combobox.Status>{status}</Combobox.Status>
<Combobox.List>
{(user: User) => (
<Combobox.Item key={user.id} value={user}>
{user.name}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
</div>
);
}
function userName(user: User) {
return user.name;
}
function userId(user: User) {
return String(user.id);
}

onInputValueChange receives a reason as its second argument: 'input' when someone typed, 'item-press', 'clear' or 'blur' when the component reset the text. Use it to skip a request you don’t need.

Give the Root onLoadMore. It is called when the end of the list scrolls into view, and not while loading is set. While loading, the list is aria-busy and Empty stays hidden.

Issue #1
Issue #2
Issue #3
Issue #4
Issue #5
Issue #6
Issue #7
Issue #8
Issue #9
Issue #10
Issue #11
Issue #12
Issue #13
Issue #14
Issue #15
Issue #16
Issue #17
Issue #18
Issue #19
Issue #20
20 of 200
import { useCallback, useState } from 'react';
import { Combobox } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
const PAGE = 20;
const TOTAL = 200;
const allIssues = Array.from({ length: TOTAL }, (_, i) => `Issue #${i + 1}`);
/** Stands in for a paged API: the next 20 after `offset`. */
function fetchPage(offset: number): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => resolve(allIssues.slice(offset, offset + PAGE)), 300),
);
}
export default function ComboboxLazy() {
const [items, setItems] = useState(() => allIssues.slice(0, PAGE));
const [loading, setLoading] = useState(false);
// Called when the end of the list scrolls into view, and never while `loading`.
const loadMore = useCallback(() => {
if (items.length >= TOTAL) return;
setLoading(true);
fetchPage(items.length).then((page) => {
setItems((current) => [...current, ...page]);
setLoading(false);
});
}, [items.length]);
return (
<div style={field}>
<label htmlFor="combobox-issue">Issue</label>
<Combobox.Root items={items} onLoadMore={loadMore} loading={loading}>
<Combobox.Input id="combobox-issue" placeholder="Scroll the list to load more" />
<Combobox.Popup>
<Combobox.List>
{(issue: string) => (
<Combobox.Item key={issue} value={issue}>
{issue}
</Combobox.Item>
)}
</Combobox.List>
<Combobox.Status>
{loading ? 'Loading more…' : `${items.length} of ${TOTAL}`}
</Combobox.Status>
</Combobox.Popup>
</Combobox.Root>
</div>
);
}

For thousands of items, render only the visible rows with a virtualizer such as TanStack Virtual. The virtualizer needs to know how many rows there are, so filter the items yourself, with defaultFilter or your own, and pass them with filter={null}. onItemHighlighted tells it which row to scroll to. The highlighted option has to be rendered for aria-activedescendant to reach it.

import { useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Combobox } from '@arun-dev/ui';
import { defaultFilter } from '@arun-dev/headless/combobox';
function BigCombobox({ all }: { all: string[] }) {
const [query, setQuery] = useState('');
const items = useMemo(() => defaultFilter(all, query, String), [all, query]);
const scrollRef = useRef<HTMLElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
});
return (
<Combobox.Root
items={items}
filter={null}
// Only typed text filters; a picked label or a reset shows everything.
onInputValueChange={(text, { reason }) => setQuery(reason === 'input' ? text : '')}
onItemHighlighted={(_, { index, reason }) => {
if (reason === 'keyboard') virtualizer.scrollToIndex(index);
}}
>
<Combobox.Input aria-label="Item" />
<Combobox.Popup ref={scrollRef}>
<Combobox.List style={{ blockSize: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => {
const item = items[row.index];
return (
<Combobox.Item
key={item}
value={item}
style={{ position: 'absolute', insetBlockStart: row.start, inlineSize: '100%' }}
>
{item}
</Combobox.Item>
);
})}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
);
}

Flatten the groups into rows, labels and items, for the virtualizer, and wrap the rows in view in their Group. Keep a group’s label when it scrolls out, hidden, so the group stays named. onItemHighlighted gives the index among items, so map it to its row.

// Memoised: a new array each render is a new result list, which clears the highlight.
const groups = useMemo(() => filterGroups(regions, query), [query]);
const rows = useMemo(
() => groups.flatMap((group) => [{ group }, ...group.items.map((item) => ({ group, item }))]),
[groups],
);
const rowOfItem = rows.flatMap((row, i) => (row.item ? [i] : []));
<Combobox.Root
items={groups}
filter={null}
onItemHighlighted={(_, { index, reason }) => {
if (reason === 'keyboard') virtualizer.scrollToIndex(rowOfItem[index]);
}}
>
…
<Combobox.List>
{inViewByGroup(virtualizer.getVirtualItems(), rows).map(({ group, rows: inView }) => (
<Combobox.Group key={group.label}>
<Combobox.GroupLabel hidden={inView[0].item !== undefined}>{group.label}</Combobox.GroupLabel>
{inView.map(({ item }) => item && <Combobox.Item key={item} value={item}>{item}</Combobox.Item>)}
</Combobox.Group>
))}
</Combobox.List>

A disabled item is skipped by the arrow keys only once it has rendered. That holds for every virtualized list, grouped or not.

Below about a thousand items, you rarely need this. Filtering runs behind useDeferredValue, so typing stays responsive, and moving the highlight re-renders only the two items it moves between.

Give the Root a name. It renders one hidden input per selected item, holding the item’s key (itemToKey), so the form submits name=key once per item. form.reset() returns the selection and the text to what they were at mount.

Add required and the form won’t submit until something is selected. The browser reports it with its own message, the input matches :invalid, and form.checkValidity() returns false. Text that was typed but never picked counts as nothing selected. With multiple, one chip is enough.

<Combobox.Root items={countries} name="country" required>

With react-hook-form, bind it through Controller and its rules={{ required: true }}, and pass aria-invalid to Combobox.Input. The field’s border turns red from it.

Combobox.Root props
PropTypeDefaultDescription
itemsT[]Every item the list can show. With server search, the current results. Selected items need not be among them.
itemToString(item: T) => stringAn item's label: what the input shows once it is picked, and what the default filter matches. Defaults to a string item itself, else its label.
itemToKey(item: T) => stringAn item's identity, and the value the form submits. Defaults to itemToString.
filter((items: T[], query: string, itemToString) => T[]) | nullNarrows, and may rank, the items for the typed text. Omitted: a case- and accent-insensitive "contains". null: no filtering, for server search.
multiplebooleanfalseLets more than one item be selected. value is then an array.
valueT | null | T[]Controlled selection. Provide onValueChange alongside it. Use null, or [] with multiple, for none — never undefined.
defaultValueT | null | T[]Initial selection when uncontrolled. Read once, at mount.
onValueChange(value, { reason }) => voidCalled on every change of the selection, with why: 'item-press', 'clear', 'chip-remove', 'input' or 'form-reset'.
inputValuestringControlled text of the input. Provide onInputValueChange alongside it.
defaultInputValuestringInitial text when uncontrolled. Defaults to the selected item's label, without multiple.
onInputValueChange(text, { reason }) => voidCalled on every change of the text, with why: 'input' when typed, or 'item-press', 'clear', 'escape', 'outside-press' or 'blur' when the component reset it, or 'value-change' when it followed a new value.
openbooleanControlled open state. Provide onOpenChange alongside it.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open, { reason }) => voidCalled on every request to open or close, with why.
onItemHighlighted(item: T | undefined, { index, reason }) => voidCalled when the highlight moves, with its index in the filtered list — for a virtualizer to scroll to.
onLoadMore() => voidCalled when the end of the list scrolls into view and loading is not set.
loadingbooleanfalseMore items are on their way: the list is aria-busy, and Empty stays hidden.
disabledbooleanfalseDisables the input and every button.
requiredbooleanfalseSomething must be selected before the form submits. Reported by the browser's own validation, on the input.
namestringSubmits each selected item's key under this name, from hidden inputs.
formstringAssociates the hidden inputs with a <form> by id, when rendered outside it.
childrenReactNodeThe other parts.
Combobox.Input props
PropTypeDefaultDescription
startSlotReactNodeContent before the chips and text, inside the box — a search icon. Mark it aria-hidden.
clearLabelstring'Clear'Accessible name of the Clear button.
triggerLabelstring'Show options'Accessible name of the chevron that opens the list.
removeLabel(label: string) => string(label) => `Remove ${label}`Accessible name of a chip's remove button, from the item's label.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

Every other prop goes to the <input>: id, placeholder, aria-*, handlers.

Combobox.Popup props
PropTypeDefaultDescription
side'top' | 'bottom' | 'left' | 'right''bottom'Which side of the field to open on. Flips to the opposite side when there is no room.
align'start' | 'center' | 'end''start'Flush with the field's start or end edge, or centred on it.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Combobox.List props
PropTypeDefaultDescription
childrenReactNode | ((item: T, index: number) => ReactNode)A function rendering one Combobox.Item per filtered item, or the items yourself — with a virtualizer.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Combobox.Item props
PropTypeDefaultDescription
valueTThe item this option stands for — one of the Root's items.
disabledbooleanfalseCannot be picked, and the arrow keys skip it. A selected one can still be removed.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Combobox.Group props
PropTypeDefaultDescription
disabledbooleanfalseDisables every Item inside, as disabled on an <optgroup> does.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

Combobox.GroupLabel takes the props of the <div> it renders, plus render and ref.

Combobox.Empty and Combobox.Status take the props of the <div> they render, plus render and ref.

  • Keep itemToString, itemToKey and filter stable: define them outside the component, or wrap them in useCallback. A new function each render filters the list again on every render.
  • Label the field with a <label htmlFor> pointing at the Input’s id, or with aria-label.
  • Not supported, by design: nested groups, an input inside the popup, action rows such as “Edit labels”, an always-visible inline list, grid navigation, and a built-in virtualizer. Put an action beside the field, in a Menu or a link, and bring your own virtualizer.
  • Restyle every combobox with the --combobox-* tokens, e.g. --combobox-popup-max-height.
  • Types come from the package that owns them. ComboboxInputProps comes from @arun-dev/ui; the other parts’ props come from @arun-dev/headless/combobox, or use ComponentProps<typeof Combobox.Item>.