Combobox for React
A text field that filters a list as you type, ignores accents, highlights the match and can create new entries.
One of 7 components in Pickers and selections
A select you can type into
A combobox is a text field with a list that filters as you type, the right control when there are too many options to scroll. This one follows the combobox pattern, the list opens on typing or with the arrow key, arrows move through matches and Enter picks one.
Filter as you type matches anywhere in the label and ignores accents and case, so cafe finds Café. It highlights the matched part of each option, shows a count of results for screen readers and offers to create a new entry when nothing fits, which suits tags, cities and customers that grow over time.
Use a combobox from about fifteen options up. Show a few suggestions before the first key, recent or popular entries help people start. Decide whether new entries are allowed and say so in the empty state, a silent no match leaves people guessing.
Variants
Details
What Combobox brings along, read from the registry and the source.
- Category
- Pickers and selections
- Area
- Elements
- Variants
- 1
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/combobox.tsx
- npm packages
- None
- Languages
- English and German, 5 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ComboboxProps interface from components/ui/combobox.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
optionsrequiredSelectOption[]valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidonCreateoptional(text: string) => voidOffer to create the typed text when nothing matches
placeholderoptionalstringsizeoptionalControlSizeiconoptionalIconNamenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleanfilteroptional(option: SelectOption, query: string) => booleanCustom matching. Default: every word of the query appears in label or description.
defaultQueryoptionalstringOpen on first render with this query, for demos
defaultOpenoptionalboolean
Built-in texts
Combobox writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 5 texts
| Key | English | German |
|---|---|---|
| empty | English No match. Try another word. | German Kein Treffer. Versuch ein anderes Wort. |
| clear | English Clear | German Leeren |
| toggle | English Show options | German Optionen zeigen |
| results | English results | German Treffer |
| create | English Create | German Anlegen |
Accessibility
Read from the source of the component.
- Combobox handles the keys Escape, Enter, Arrow up, Arrow down and Tab itself.
- Sets aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles combobox, listbox and option.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 252 lines
'use client';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, ListboxGroups, revealOption, unfold, type SelectOption } from '@/components/ui/select';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';
const TEXTS = {
en: { empty: 'No match. Try another word.', clear: 'Clear', toggle: 'Show options', results: 'results', create: 'Create' },
de: { empty: 'Kein Treffer. Versuch ein anderes Wort.', clear: 'Leeren', toggle: 'Optionen zeigen', results: 'Treffer', create: 'Anlegen' },
};
export interface ComboboxProps extends FieldOwnProps {
options: SelectOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Offer to create the typed text when nothing matches */
onCreate?: (text: string) => void;
placeholder?: string;
size?: ControlSize;
icon?: IconName;
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
/** Custom matching. Default: every word of the query appears in label or description. */
filter?: (option: SelectOption, query: string) => boolean;
/** Open on first render with this query, for demos */
defaultQuery?: string;
defaultOpen?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const defaultFilter = (o: SelectOption, q: string) => {
const hay = norm(`${o.label} ${o.description ?? ''}`);
return norm(q).split(/\s+/).filter(Boolean).every((w) => hay.includes(w));
};
/** Marks the typed part in a label */
function Highlight({ text, query }: { text: string; query: string }) {
const q = norm(query.trim());
if (!q) return <>{text}</>;
const i = norm(text).indexOf(q.split(/\s+/)[0]);
if (i < 0) return <>{text}</>;
const len = q.split(/\s+/)[0].length;
return (
<>
{text.slice(0, i)}
<mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-inherit">{text.slice(i, i + len)}</mark>
{text.slice(i + len)}
</>
);
}
function ComboboxBox({ options, value: valueProp, defaultValue, onValueChange, onCreate, placeholder, size = 'md', icon = 'search', name, id, disabled, required, invalid, filter = defaultFilter, defaultQuery, defaultOpen, labels, className }: Omit<ComboboxProps, keyof FieldOwnProps>) {
const t = useTexts(TEXTS, labels);
const c = useControl({ id, invalid, disabled, required });
const [inner, setInner] = useState(defaultValue);
const value = valueProp ?? inner;
const selected = options.find((o) => o.value === value);
const [query, setQuery] = useState(defaultQuery ?? selected?.label ?? '');
const [typing, setTyping] = useState(defaultQuery !== undefined);
const [open, setOpen] = useState(Boolean(defaultOpen));
const [active, setActive] = useState(0);
const mounted = useMounted();
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listId = `${baseId}-list`;
const shown = useMemo(() => (typing && query.trim() ? options.filter((o) => filter(o, query)) : options), [options, query, typing, filter]);
const canCreate = Boolean(onCreate && typing && query.trim() && !options.some((o) => norm(o.label) === norm(query.trim())));
const count = shown.length + (canCreate ? 1 : 0);
const close = useCallback(() => {
setOpen(false);
setTyping(false);
setQuery((q) => (selected ? selected.label : q));
}, [selected]);
const refs = useRef([boxRef, listRef]).current;
useDismiss(open, close, refs);
useShake(boxRef, c.invalid);
useEffect(() => {
if (open) revealOption(active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`);
}, [open, active, baseId, shown.length]);
const pick = (o: SelectOption) => {
if (o.disabled) return;
setInner(o.value);
onValueChange?.(o.value);
setQuery(o.label);
setTyping(false);
setOpen(false);
};
const create = () => {
onCreate?.(query.trim());
setTyping(false);
setOpen(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open) {
setOpen(true);
setActive(Math.max(0, shown.findIndex((o) => o.value === value)));
return;
}
setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + count) % Math.max(count, 1));
} else if (e.key === 'Enter' && open) {
e.preventDefault();
if (active < shown.length && shown[active]) pick(shown[active]);
else if (canCreate) create();
} else if (e.key === 'Escape') {
if (open) close();
else if (query) {
setQuery('');
setTyping(true);
}
} else if (e.key === 'Tab' && open) close();
};
const activeId = open && count ? (active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`) : undefined;
return (
<>
<div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/combo relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
<Icon name={icon} size={16} className="pointer-events-none ms-3.5 shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/combo:text-muted-foreground" />
<input
ref={inputRef}
id={c.id}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-activedescendant={activeId}
aria-invalid={c['aria-invalid']}
aria-describedby={c['aria-describedby']}
disabled={c.disabled}
required={c.required}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setTyping(true);
setActive(0);
setOpen(true);
}}
onClick={() => setOpen(true)}
onKeyDown={onKeyDown}
className="h-full w-0 min-w-0 flex-1 bg-transparent ps-2.5 text-inherit outline-none placeholder:text-subtle-foreground"
/>
{query && !c.disabled && (
<button
type="button"
aria-label={t.clear}
onClick={() => {
setQuery('');
setTyping(true);
setOpen(true);
inputRef.current?.focus();
}}
className="relative grid size-7 place-items-center rounded-md text-subtle-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-2 after:content-[''] hover:bg-accent hover:text-foreground active:scale-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none"
>
<Icon name="x" size={14} />
</button>
)}
<button
type="button"
tabIndex={-1}
aria-label={t.toggle}
onClick={() => {
setOpen(!open);
inputRef.current?.focus();
}}
className="me-1.5 grid size-7 place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Icon name="chevrons-up-down" size={15} className={cn('transition-[scale] duration-300 ease-[var(--ease-kit)]', open && 'scale-110')} />
</button>
</div>
{name && <input type="hidden" name={name} value={value ?? ''} />}
<span aria-live="polite" className="sr-only">
{open && typing ? `${shown.length} ${t.results}` : ''}
</span>
{mounted && open && (
<Floating anchor={boxRef} side="bottom" align="start" offset={6} matchWidth ref={listRef}>
{(pos) => (
<div
id={listId}
role="listbox"
aria-labelledby={c.id}
className={cn('kit-scrollbar relative max-h-72 overflow-y-auto overscroll-contain p-1.5', panelSurface, unfold(pos?.side).className)}
>
<style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
<ActiveMarker id={activeId} version={`${query}|${shown.length}`} />
{shown.length > 0 && (
<ListboxGroups
options={shown}
baseId={`${baseId}-opt`}
activeIndex={active}
isSelected={(o) => o.value === value}
onPick={pick}
onHover={(i) => setActive(i)}
render={(o) => <Highlight text={o.label} query={typing ? query : ''} />}
/>
)}
{canCreate && (
<div
id={`${baseId}-create`}
role="option"
aria-selected={false}
data-active={active === shown.length || undefined}
onPointerMove={() => setActive(shown.length)}
onPointerDown={(e) => e.preventDefault()}
onClick={create}
style={{ '--i': shown.length } as React.CSSProperties}
className="kitfx-select-row relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm text-foreground max-sm:min-h-11"
>
<Icon name="plus" size={16} className="text-muted-foreground" />
<span className="truncate">
{t.create} <span className="font-medium">{query.trim()}</span>
</span>
</div>
)}
{count === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>}
</div>
)}
</Floating>
)}
</>
);
}
/**
* A text field that filters a list as you type. Arrows move, Enter picks, Escape closes then clears.
* Ignores accents and word order, highlights the match, can offer to create a new entry.
* <Combobox label="Country" options={countries} placeholder="Search a country" />
*/
export function Combobox({ label, hint, error, optional, labelAction, className, ...rest }: ComboboxProps) {
const wrapped = Boolean(label || hint || error);
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <ComboboxBox {...rest} className={wrapped ? undefined : className} />);
}Frequent questions
How do I install Combobox?
With aiw add @ai-website/combobox. The command puts components/ui/combobox.tsx into your project, the code is yours from then on. It brings along icon, input, select and popover. It needs no further npm packages.
Can I translate the texts of Combobox?
Yes. Combobox carries 5 texts of its own in English and German, such as “No match. Try another word.”, “Clear” and “Show options”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Combobox?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Combobox with aiw_components and install it with aiw_add @ai-website/combobox.