Skip to content

Combobox ​

A text field that suggests as you type, as a compound component on Base UI's autocomplete. The text stays free: a suggestion offers text for the field, it does not hold a value of its own. To pick one of a fixed set of options, use a Select with searchable.

tsx
import { Combobox } from '@adecore/ui';
tsx
import { useState } from 'react';
import { Search } from 'lucide-react';
import { Combobox, matchScore } from '@adecore/ui';

const KEYS: Record<string, { about: string; values: string[] }> = {
    is: { about: 'unresolved, resolved or ignored', values: ['unresolved', 'resolved', 'ignored'] },
    level: { about: 'error, fatal, warning', values: ['error', 'fatal', 'warning'] },
    release: { about: 'A release, such as 1.5.0', values: ['1.5.0', '1.4.2', '1.4.1'] },
    environment: { about: 'production, staging', values: ['production', 'staging'] }
};

/* The word the caret sits in, at the end of the field here: a key so far, or a key and the start of its value. */
const lastWord = (text: string): { start: number; key: string | null; prefix: string } => {
    const start = text.lastIndexOf(' ') + 1;
    const word = text.slice(start);
    const colon = word.indexOf(':');
    return colon === -1 ? { start, key: null, prefix: word } : { start, key: word.slice(0, colon), prefix: word.slice(colon + 1) };
};

export default function ComboboxDemo() {
    const [text, setText] = useState('');
    const [focused, setFocused] = useState(false);
    const word = lastWord(text);
    const options = word.key === null ? Object.keys(KEYS) : (KEYS[word.key]?.values ?? []);
    const suggestions = options.filter((option) => matchScore(option, word.prefix) !== null);

    const accept = (suggestion: string): void => {
        const replacement = word.key === null ? `${suggestion}:` : `${word.key}:${suggestion} `;
        setText(text.slice(0, word.start) + replacement);
    };

    return (
        <div className="w-80">
            <Combobox.Root
                items={suggestions}
                filter={null}
                value={text}
                onValueChange={(next, details) => (details.reason === 'item-press' ? accept(next) : setText(next))}
                // Open as long as the field has focus, so taking a key leads straight to its values.
                open={focused && suggestions.length > 0}
                onOpenChange={(open, details) => details.reason !== 'item-press' && setFocused(open)}
                autoHighlight
            >
                <Combobox.Input
                    icon={Search}
                    aria-label="Search issues"
                    placeholder="Search, or filter as level:error"
                    onFocus={() => setFocused(true)}
                    onBlur={() => setFocused(false)}
                />
                <Combobox.Popup>
                    <Combobox.List>
                        {(suggestion: string) => (
                            <Combobox.Item key={suggestion} value={suggestion} trailing={word.key === null ? KEYS[suggestion]?.about : undefined}>
                                {word.key === null ? <span className="font-mono text-code">{suggestion}:</span> : suggestion}
                            </Combobox.Item>
                        )}
                    </Combobox.List>
                </Combobox.Popup>
            </Combobox.Root>
        </div>
    );
}

The demo completes the word at the end of the field: first a filter key, then a value of that key. It keeps the list open as long as the field has focus, by holding open itself and ignoring the close that a picked row asks for.

Parts ​

Every part takes Base UI's own props; the library adds the look, the portal and the placement.

PartWhat it is
Combobox.RootBase UI's Autocomplete.Root, as it is. value and onValueChange hold the text, items the suggestions, filter how they match the text (null when the caller filters, or the items come from a request). open, onOpenChange, autoHighlight and onItemHighlighted work as in Base UI.
Combobox.InputThe field, drawn as an Input: size, mono and an icon in front. Inside a Field it takes the label and the hint.
Combobox.PopupThe portal, the positioner and the menu surface in one part. It hangs under the field, 6 pixels away, and is at least as wide as the field. It takes Base UI's placement props (side, align, sideOffset, ...) to say otherwise.
Combobox.ListThe rows. Hand it a function of an item to draw what items holds after the filter. Its accessible name is "Suggestions" from the ui namespace (combobox.suggestions).
Combobox.ItemOne row, drawn as a menu row. Its children are the label.
Combobox.Group, Combobox.GroupLabel, Combobox.CollectionA group of rows under a label. Hand items a list of { value, items } and draw each group's rows with a Collection.
Combobox.EmptyShows its children while the list has no rows. Needs items on the root.
Combobox.StatusA line read out as it changes: a search that is still running, how many it found.

Empty and Status stay in the document so a screen reader hears them change, and take no room while they have nothing to say.

A row ​

Combobox.Item lays out a row from a few props beside its label:

PropType
iconReactNodeBefore the label, such as a 14px Icon.
hintReactNodeBeside the label in a smaller, fainter type, and the first to give way when the row is narrow: the folder a file sits in.
descriptionReactNodeA second line under the label.
trailingReactNodeAt the end of the row: a state, a count, a reference.
shortcutShortcutThe shortcut of the row, printed as a Kbd at its end.
unstyledbooleanLeaves the row out, for a row that lays out itself, such as lines of source. It still takes the highlight (data-highlighted), Enter and the click.

onClick runs for a click and for Enter on the highlighted row. Base UI clicks the row for Enter, so a modifier held with it does not reach onClick. Read Cmd+Enter in the field's onKeyDown instead, with the row onItemHighlighted last named.

Suggestions from a request ​

Pass filter={null}, keep the text with value and onValueChange, and hand items what the last answer held. Keep that answer until the next one lands, so the list does not empty and fill again with every key. Combobox.Status says the search is still running.

tsx
import { useEffect, useState } from 'react';
import { User, Users } from 'lucide-react';
import { Combobox, Icon, Spinner, matchScore } from '@adecore/ui';

interface Person {
    id: string;
    name: string;
    role: string;
    team: boolean;
}

interface Group {
    value: string;
    items: Person[];
}

const PEOPLE: Person[] = [
    { id: 'ada', name: 'Ada Lovelace', role: 'Engines', team: false },
    { id: 'alan', name: 'Alan Turing', role: 'Codes', team: false },
    { id: 'grace', name: 'Grace Hopper', role: 'Compilers', team: false },
    { id: 'design', name: 'Design', role: '4 people', team: true },
    { id: 'desktop', name: 'Desktop', role: '6 people', team: true }
];

/* Stands in for a request: the answer lands a moment after the last key. */
function useSearch(query: string): { groups: Group[]; searching: boolean } {
    const [answer, setAnswer] = useState<{ query: string; groups: Group[] }>({ query: '', groups: [] });
    useEffect(() => {
        if (query.trim() === '') {
            return;
        }
        const timer = window.setTimeout(() => {
            const found = PEOPLE.filter((person) => matchScore(person.name, query) !== null);
            const groups = [
                { value: 'People', items: found.filter((person) => !person.team) },
                { value: 'Teams', items: found.filter((person) => person.team) }
            ].filter((group) => group.items.length > 0);
            setAnswer({ query, groups });
        }, 400);
        return () => window.clearTimeout(timer);
    }, [query]);
    // The last answer stays until the next one lands, so the list does not flicker while typing.
    return { groups: query.trim() === '' ? [] : answer.groups, searching: query.trim() !== '' && answer.query !== query };
}

export default function ComboboxAsyncDemo() {
    const [query, setQuery] = useState('');
    const { groups, searching } = useSearch(query);

    return (
        <div className="w-80">
            <Combobox.Root items={groups} filter={null} value={query} onValueChange={setQuery} itemToStringValue={(person: Person) => person.name}>
                <Combobox.Input aria-label="Assign to" placeholder="Assign to a person or a team" />
                <Combobox.Popup aria-busy={searching || undefined}>
                    <Combobox.Status>
                        {searching && (
                            <span className="flex items-center justify-center gap-2">
                                <Spinner size={12} /> Searching
                            </span>
                        )}
                    </Combobox.Status>
                    <Combobox.Empty>{!searching && 'Nobody by that name'}</Combobox.Empty>
                    <Combobox.List>
                        {(group: Group) => (
                            <Combobox.Group key={group.value} items={group.items}>
                                <Combobox.GroupLabel>{group.value}</Combobox.GroupLabel>
                                <Combobox.Collection>
                                    {(person: Person) => (
                                        <Combobox.Item
                                            key={person.id}
                                            value={person}
                                            icon={<Icon icon={person.team ? Users : User} size={14} />}
                                            description={person.role}
                                        >
                                            {person.name}
                                        </Combobox.Item>
                                    )}
                                </Combobox.Collection>
                            </Combobox.Group>
                        )}
                    </Combobox.List>
                </Combobox.Popup>
            </Combobox.Root>
        </div>
    );
}

Keyboard ​

Base UI handles the keyboard. Typing opens the list, the arrows move the highlight while the caret stays in the field, Enter takes the highlighted row and Escape closes the list. With autoHighlight the first row is highlighted as soon as the list opens, so Enter takes it at once.

Types ​

ComboboxInputProps, ComboboxPopupProps and ComboboxItemProps are exported types.

Licenses and third-party notices are listed in each package.