Combobox
A text field that filters a list of options as you type.
'use client';
import { Field } from '@/components/panelui/field';
import { Combobox } from '@/components/panelui/combobox';
import { useState } from 'react';
const FRAMEWORKS = [
{ value: 'expo', label: 'Expo' },
{ value: 'react-native', label: 'React Native' },
{ value: 'next', label: 'Next.js' },
{ value: 'remix', label: 'Remix' },
{ value: 'astro', label: 'Astro' },
{ value: 'sveltekit', label: 'SvelteKit' },
];
export default function ComboboxOverview() {
const [framework, setFramework] = useState<string | undefined>();
return (
<Field className="w-full max-w-xs">
<Field.Label>Framework</Field.Label>
<Combobox
value={framework}
onValueChange={setFramework}
placeholder="Search frameworks"
clearable
>
{FRAMEWORKS.map((framework) => (
<Combobox.Item key={framework.value} value={framework.value} label={framework.label} />
))}
</Combobox>
<Field.Description>
{framework ? `Building with ${FRAMEWORKS.find((item) => item.value === framework)?.label}.` : 'Pick one to continue.'}
</Field.Description>
</Field>
);
}Use it to choose from a list of options the reader already knows the name of: a city, a repository, a tag. Typing narrows the list, so the hand stays on the keyboard and a long list stays short.
The difference from Select is where the typing happens. A select is a button that opens a list, and its optional filter lives inside that list once it opens. A combobox is the field itself: the options appear while the reader is already typing.
Use a select when the options are few enough to recognise by scrolling, and a combobox when there are enough of them that the name is easier to type than the list is to read.
Installation
npx shadcn@latest add https://panelui.dev/r/web/combobox.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/combobox.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/combobox.jsonbunx shadcn@latest add https://panelui.dev/r/web/combobox.jsonThe source is copied into components/panelui/, where you can edit it.
Usage
import { Combobox } from "@/components/panelui/combobox"<Combobox value={framework} onValueChange={setFramework} placeholder="Search frameworks" clearable>
<Combobox.Item value="expo" label="Expo" />
<Combobox.Item value="next" label="Next.js" />
</Combobox>Composition
<Combobox>
<Combobox.Item value="…" label="…" />
<Combobox.Group label="…">
<Combobox.Item value="…" label="…" />
</Combobox.Group>
</Combobox>Combobox.Item— One option.valueidentifies it,labelis what is shown and what the default filter matches on,descriptionadds a second line,startdraws something before the label, anddisabledkeeps it listed but unselectable.Combobox.Group— A titled run of options. Presentational only: a grouped Combobox reports the same values a flat one would, and a group with nothing left in it disappears from the filtered list.
Examples
Grouped options
Options under headings. Typing drops a group once the query empties it, rather than leaving its heading over nothing.
'use client';
import { Combobox } from '@/components/panelui/combobox';
export default function ComboboxGrouped() {
return (
<div className="w-full max-w-xs">
<Combobox placeholder="Search frameworks" openOnFocus>
<Combobox.Group label="Native">
<Combobox.Item value="expo" label="Expo" />
<Combobox.Item value="react-native" label="React Native" />
</Combobox.Group>
<Combobox.Group label="Web">
<Combobox.Item value="next" label="Next.js" />
<Combobox.Item value="remix" label="Remix" />
<Combobox.Item value="astro" label="Astro" />
<Combobox.Item value="sveltekit" label="SvelteKit" />
</Combobox.Group>
</Combobox>
</div>
);
}Several at once
mode="multiple" draws each pick as a removable chip in front of the input and keeps the list open between picks.
'use client';
import { Field } from '@/components/panelui/field';
import { Combobox } from '@/components/panelui/combobox';
import { useState } from 'react';
const FRAMEWORKS = [
{ value: 'expo', label: 'Expo' },
{ value: 'react-native', label: 'React Native' },
{ value: 'next', label: 'Next.js' },
{ value: 'remix', label: 'Remix' },
{ value: 'astro', label: 'Astro' },
{ value: 'sveltekit', label: 'SvelteKit' },
];
export default function ComboboxMultiple() {
const [picked, setPicked] = useState<string[]>(['expo']);
return (
<Field className="w-full max-w-sm">
<Field.Label>Frameworks</Field.Label>
<Combobox
mode="multiple"
value={picked}
onValueChange={setPicked}
placeholder="Add a framework"
clearable
>
{FRAMEWORKS.map((framework) => (
<Combobox.Item key={framework.value} value={framework.value} label={framework.label} />
))}
</Combobox>
<Field.Description>
{picked.length === 0 ? 'Nothing picked yet.' : `${picked.length} picked. The list stays open between picks.`}
</Field.Description>
</Field>
);
}Values it does not know about
allowCustomValue keeps whatever was typed when it matches no option, so the list suggests rather than restricts. Press Enter to add it.
'use client';
import { Field } from '@/components/panelui/field';
import { Combobox } from '@/components/panelui/combobox';
import { useState } from 'react';
const SUGGESTIONS = ['design', 'engineering', 'research', 'growth', 'support', 'operations'];
export default function ComboboxCustom() {
const [tags, setTags] = useState<string[]>(['design', 'engineering']);
return (
<Field className="w-full max-w-sm">
<Field.Label>Tags</Field.Label>
<Combobox
mode="multiple"
value={tags}
onValueChange={setTags}
allowCustomValue
placeholder="Add a tag"
clearable
>
{SUGGESTIONS.map((tag) => (
<Combobox.Item key={tag} value={tag} label={tag} />
))}
</Combobox>
<Field.Description>
Type a tag that is not listed and press Enter to add it.
</Field.Description>
</Field>
);
}Results from a server
filter={false} stops the field filtering again, for results already matched elsewhere. Hold the text with inputValue and onInputValueChange, and show loading while the request is out.
'use client';
import { Field } from '@/components/panelui/field';
import { Combobox } from '@/components/panelui/combobox';
import { useEffect, useState } from 'react';
const CITIES = [
{ value: 'lisbon', label: 'Lisbon' },
{ value: 'berlin', label: 'Berlin' },
{ value: 'helsinki', label: 'Helsinki' },
{ value: 'bogota', label: 'Bogotá' },
{ value: 'toronto', label: 'Toronto' },
{ value: 'vancouver', label: 'Vancouver' },
{ value: 'dubai', label: 'Dubai' },
{ value: 'singapore', label: 'Singapore' },
{ value: 'seoul', label: 'Seoul' },
];
/** Stands in for a search request that takes a moment. */
function useCitySearch(query: string) {
const [state, setState] = useState({ loading: false, results: CITIES });
useEffect(() => {
setState((current) => ({ ...current, loading: true }));
const timer = setTimeout(() => {
const needle = query.trim().toLowerCase();
setState({
loading: false,
results: needle ? CITIES.filter((city) => city.label.toLowerCase().includes(needle)) : CITIES,
});
}, 400);
return () => clearTimeout(timer);
}, [query]);
return state;
}
export default function ComboboxAsync() {
const [query, setQuery] = useState('');
const [city, setCity] = useState<string | undefined>();
const { loading, results } = useCitySearch(query);
return (
<Field className="w-full max-w-xs">
<Field.Label>City</Field.Label>
<Combobox
value={city}
onValueChange={setCity}
inputValue={query}
onInputValueChange={setQuery}
filter={false}
loading={loading}
loadingMessage="Looking up cities"
emptyMessage="No city by that name"
placeholder="Search cities"
clearable
>
{results.map((result) => (
<Combobox.Item key={result.value} value={result.value} label={result.label} />
))}
</Combobox>
<Field.Description>The server does the matching; the field only shows what came back.</Field.Description>
</Field>
);
}Expanding in place
presentation="inline" puts the list in normal layout flow, pushing what is below it down instead of covering it.
'use client';
import { Combobox } from '@/components/panelui/combobox';
export default function ComboboxInline() {
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<Combobox presentation="inline" placeholder="Search frameworks">
<Combobox.Item value="expo" label="Expo" />
<Combobox.Item value="react-native" label="React Native" />
<Combobox.Item value="next" label="Next.js" />
<Combobox.Item value="remix" label="Remix" />
</Combobox>
<p className="text-xs text-muted-foreground">The list takes its place in the page instead of covering it.</p>
</div>
);
}API Reference
Combobox.Item
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Identifies the option; what value and onValueChange carry. |
label | string | — | What is shown, and what the default filter matches on. |
disabled | boolean | false | Shows the option but refuses it. Kept in the list rather than dropped from it, because an option that vanishes reads as one that never existed. |
start | ReactNode | — | Anything to draw before the label — an avatar, a flag, a status dot. |
description | ReactNode | — | A second line under the label, for what the label alone cannot say. |
className | string | — | Extra classes for this option. |
Combobox.Group
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Heading over the run of options. Announced as a group label, so a screen reader reaching the group is told what it is before walking into it. |
className | string | — | Extra classes for the group wrapper. |
labelClassName | string | — | Extra classes for the heading. |
Combobox
| Prop | Type | Default | Description |
|---|---|---|---|
mode | Mode | — | One value or several. multiple draws the chosen options as chips and keeps the list open. |
value | ComboboxSelection[Mode] | — | Controlled selection. Its shape follows mode. |
defaultValue | ComboboxSelection[Mode] | — | Starting selection when uncontrolled. |
onValueChange | (value: ComboboxSelection[Mode]) => void | — | Called with the next selection, whose shape follows mode. |
inputValue | string | — | Controlled query — the text actually in the field. Pair it with onInputValueChange when the options are fetched for it. |
defaultInputValue | string | — | Starting query when uncontrolled. |
onInputValueChange | (value: string) => void | — | Called with the typed text. |
placeholder | string | 'Search' | What the field says while empty. Default Search. |
disabled | boolean | false | Whether the field and its list are unavailable. |
presentation | ComboboxPresentation | 'overlay' | Where the options appear. Default overlay. |
filter | boolean | ((option: ComboboxItemProps, query: string) => boolean) | true | Narrow the options to the query here. true matches case-insensitively on any part of an option's label; pass a function to match on something else. Pass false when a server is doing the matching: the options you render are then shown exactly as given. |
allowCustomValue | boolean | false | Let the typed text become the value when it matches no option, committed on Enter. Turns the list into a set of suggestions rather than the set of legal answers. |
loading | boolean | false | Show a spinner in place of the list. For options still being fetched. |
emptyMessage | string | 'No matches' | Shown in place of the list when nothing matches. Default No matches. |
loadingMessage | string | 'Searching' | Shown in place of the list while loading. Default Searching. |
clearable | boolean | false | Offer a ✕ that clears the query and the selection. |
openOnFocus | boolean | false | Open the list as soon as the field takes focus. |
onOpenChange | (open: boolean) => void | — | Called when the list opens or closes. |
contentWidth | 'field' | 'content' | number | 'field' | Width of the floating list. field matches the field, content sizes to the longest option, or pass a pixel value. overlay only. |
offset | number | 8 | Gap between the field and the floating list, in pixels. Default 8. |
listClassName | string | — | Extra classes for the list surface. |
accessibilityLabel | string | — | Accessible name for the field. |
id | string | — | The input's id, so a <label htmlFor> can name it. |
name | string | — | Form field name, so the value is sent with the form. |
form | string | — | The form the field belongs to. |
required | boolean | — | Whether the field must have a value before the form can be sent. |
container | BaseCombobox.Portal.Props['container'] | — | Where in the document an overlay list is put. Default document.body. |
Combobox.Item and Combobox.Group take only the props listed here: they describe the options, and do not render an element of their own. Combobox takes the props of the wrapper it renders, plus className.
Notes
One value or several
mode="multiple" draws the chosen options as removable chips in front of the input and keeps the list open between picks, so several can be taken in a row. The value is an array there and a single string otherwise: the type follows mode, so nothing has to be cast.
A chip can be removed with its ✕, or from the keyboard: press Left Arrow from the start of the input to reach the chips, then Backspace or Delete to take one out.
Filtering, and turning it off
Filtering happens in the component by default, matching case-insensitively on any part of an option's label. Pass a function to filter to match on something else — a description, an alias list, an initialism.
Pass filter={false} when options come from a server that is doing the matching itself. The field then shows exactly what it was given, because a second filter over results it cannot see the query behind would only remove correct answers. Pair it with the controlled inputValue / onInputValueChange so the query can drive the request, and with loading so the list says it is working rather than saying there are no matches.
Values it does not know about
allowCustomValue lets the typed text become the value when it matches no option, committed on Enter. The list becomes a set of suggestions rather than the set of legal answers, which is what a tag field is.
Overlay or inline
overlay (the default) floats the list above the page, anchored under the field and flipped above it when there is no room below; nothing else on the screen moves. contentWidth and offset apply there only.
inline expands the list in normal layout flow instead, pushing what is below it down. Reach for it in a form where nothing should be covered.
Width
An overlay list is as wide as the field by default. contentWidth="content" sizes it to the longest option, and a number sets an exact width; a list wider than the field stays on screen by moving left or right.
Keyboard
The field is a combobox: typing filters, the arrow keys move through the options, Enter chooses the highlighted one, and Escape closes the list. Enter also takes the typed text when allowCustomValue is on and nothing matches it.
Forms
Put it in a Field to give it a label and a line of help, and pass name to send the value with the form. In multiple mode each chosen value is sent as its own entry.
Server Components
Combobox is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Combobox.Item off the import, so each part is also exported by name: ComboboxItem is Combobox.Item, and so on for the others.
import { Combobox, ComboboxItem, ComboboxGroup } from "@/components/panelui/combobox"