Combobox
When to use
Section titled “When to use”- 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:
| Component | Use it for | For example |
|---|---|---|
| RadioGroup | Up to about five options, all visible at once, as a form value | Shipping speed, plan size |
| Select | One option from a longer list, in little space, with the system picker | Country, time zone, sort order in a form |
| Combobox | A long list people search by typing, several picks, or options loaded from a server | Assign reviewers, search a city, add tags |
| ToggleGroup | A few options shown as buttons that apply at once, not a form field | Text alignment, list or grid view |
| Slider | A number in a range, where dragging is easier than typing an exact value | Volume, brightness, price range |
Choosing between entering text:
| Component | Use it for | For example |
|---|---|---|
| Input | One line of text: a name, an email, a search | Email address, search box |
| OTP input | A code of fixed length, typed or pasted one character per box | SMS code, two-factor code, PIN |
| Textarea | Several lines of free text that may wrap | Comment, description, message |
| Combobox | Typing to find and pick from a list of known options | Choose a city, add existing tags |
| Field | Not a control: wraps any of these to tie a label, a hint and an error to it | Any 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.
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> );}Anatomy
Section titled “Anatomy”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>;| Part | Renders | Does |
|---|---|---|
Root | hidden inputs, with name | holds 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.
Keyboard
Section titled “Keyboard”Focus stays in the input. The highlighted option is announced through aria-activedescendant.
| Key | Does |
|---|---|
| typing | opens the list and filters it |
| ↓ ↑ | opens the list at the selected or first item, then moves, wrapping |
| Alt+↓ | opens the list without highlighting |
| Home, End | the first or last item, while open |
| Enter | picks the highlighted item |
| Esc | closes the list, putting the text back |
| Tab | closes the list, and focus moves on |
| Backspace in an empty input | with multiple, removes the last item |
| ← at the start of the text | with multiple, moves into the chips: ← → between them, Backspace or Delete removes one |
Single and multiple
Section titled “Single and multiple”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;}| Interaction | Single | multiple |
|---|---|---|
| Pick an item | the input shows its label; the list closes | added as a chip; the text clears; the list closes |
| Pick a selected item | stays selected | removed |
| Empty the input | the selection clears | the selection stays |
| Esc, or a press outside | closes; the input goes back to the selected label | closes; the text clears |
| Clear (×) | clears the text and the selection | clears 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.
Pre-selected items
Section titled “Pre-selected items”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.
Disabled items: can’t be selected
Section titled “Disabled items: can’t be selected”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.
Selections can always be removed
Section titled “Selections can always 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.
Groups
Section titled “Groups”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.itemsalready holds only the matches. - The arrow keys move through every group as one list. They skip the labels and disabled items.
disabledon 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
itemsarray, so an item type with its ownitemsarray can’t be listed ungrouped. - Groups don’t nest, as with
<optgroup>. Combobox.Separatordraws 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.
Creating an item
Section titled “Creating an item”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:
- Track the text with
onInputValueChange. - 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. - 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.
Search
Section titled “Search”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[]Fuzzy search
Section titled “Fuzzy search”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.
Server search
Section titled “Server search”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.
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.
Lazy loading
Section titled “Lazy loading”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.
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> );}Virtualization
Section titled “Virtualization”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> );}With groups
Section titled “With groups”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
Section titled “Combobox.Root”| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | Every item the list can show. With server search, the current results. Selected items need not be among them. | |
itemToString | (item: T) => string | An 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) => string | An item's identity, and the value the form submits. Defaults to itemToString. | |
filter | ((items: T[], query: string, itemToString) => T[]) | null | Narrows, and may rank, the items for the typed text. Omitted: a case- and accent-insensitive "contains". null: no filtering, for server search. | |
multiple | boolean | false | Lets more than one item be selected. value is then an array. |
value | T | null | T[] | Controlled selection. Provide onValueChange alongside it. Use null, or [] with multiple, for none — never undefined. | |
defaultValue | T | null | T[] | Initial selection when uncontrolled. Read once, at mount. | |
onValueChange | (value, { reason }) => void | Called on every change of the selection, with why: 'item-press', 'clear', 'chip-remove', 'input' or 'form-reset'. | |
inputValue | string | Controlled text of the input. Provide onInputValueChange alongside it. | |
defaultInputValue | string | Initial text when uncontrolled. Defaults to the selected item's label, without multiple. | |
onInputValueChange | (text, { reason }) => void | Called 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. | |
open | boolean | Controlled open state. Provide onOpenChange alongside it. | |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open, { reason }) => void | Called on every request to open or close, with why. | |
onItemHighlighted | (item: T | undefined, { index, reason }) => void | Called when the highlight moves, with its index in the filtered list — for a virtualizer to scroll to. | |
onLoadMore | () => void | Called when the end of the list scrolls into view and loading is not set. | |
loading | boolean | false | More items are on their way: the list is aria-busy, and Empty stays hidden. |
disabled | boolean | false | Disables the input and every button. |
required | boolean | false | Something must be selected before the form submits. Reported by the browser's own validation, on the input. |
name | string | Submits each selected item's key under this name, from hidden inputs. | |
form | string | Associates the hidden inputs with a <form> by id, when rendered outside it. | |
children | ReactNode | The other parts. |
Combobox.Input
Section titled “Combobox.Input”| Prop | Type | Default | Description |
|---|---|---|---|
startSlot | ReactNode | Content before the chips and text, inside the box — a search icon. Mark it aria-hidden. | |
clearLabel | string | 'Clear' | Accessible name of the Clear button. |
triggerLabel | string | '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. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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
Section titled “Combobox.Popup”| Prop | Type | Default | Description |
|---|---|---|---|
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. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Combobox.List
Section titled “Combobox.List”| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((item: T, index: number) => ReactNode) | A function rendering one Combobox.Item per filtered item, or the items yourself — with a virtualizer. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Combobox.Item
Section titled “Combobox.Item”| Prop | Type | Default | Description |
|---|---|---|---|
value | T | The item this option stands for — one of the Root's items. | |
disabled | boolean | false | Cannot be picked, and the arrow keys skip it. A selected one can still be removed. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Combobox.Group
Section titled “Combobox.Group”| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disables every Item inside, as disabled on an <optgroup> does. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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,itemToKeyandfilterstable: define them outside the component, or wrap them inuseCallback. A new function each render filters the list again on every render. - Label the field with a
<label htmlFor>pointing at the Input’sid, or witharia-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.
ComboboxInputPropscomes from@arun-dev/ui; the other parts’ props come from@arun-dev/headless/combobox, or useComponentProps<typeof Combobox.Item>.