Combobox
A text input with a filterable list, for choosing from more options than a select can comfortably show.
"use client"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Field, FieldLabel } from "@/components/ui/field"
const frameworks = [
"Next.js",
"Remix",
"Astro",
"SvelteKit",
"Nuxt",
"TanStack Start",
"Vite",
]
export default function ComboboxDemo() {
return (
<Field className="w-72">
<FieldLabel htmlFor="framework">Framework</FieldLabel>
<Combobox items={frameworks} defaultValue="Next.js">
<ComboboxInput id="framework" placeholder="Search frameworks…" />
<ComboboxContent>
<ComboboxEmpty>No frameworks found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/combobox.jsonUsage
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"const frameworks = ["Next.js", "Remix", "Astro", "SvelteKit"]
<Combobox items={frameworks}>
<ComboboxInput placeholder="Search frameworks…" />
<ComboboxContent>
<ComboboxEmpty>No frameworks found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>Give the root the full items array and render the list with a function: Base UI filters the items as people type and calls the function only for the matches. ComboboxEmpty appears automatically when nothing matches.
Examples
Clearable
showClear swaps the chevron for a clear button once there's a value. Offer it whenever an empty value is valid — an unassigned task, no filter — so people don't have to select the text and delete it.
"use client"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
const assignees = [
"Ada Lovelace",
"Alan Turing",
"Grace Hopper",
"Katherine Johnson",
"Linus Torvalds",
"Margaret Hamilton",
]
export default function ComboboxClear() {
return (
<Combobox items={assignees} defaultValue="Grace Hopper">
<ComboboxInput
placeholder="Assign to…"
aria-label="Assignee"
showClear
className="w-64"
/>
<ComboboxContent>
<ComboboxEmpty>Nobody matches that name.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}Groups
Groups are just items with an items array of their own. Render them with ComboboxGroup, a ComboboxLabel heading and a ComboboxCollection for the members; filtering keeps a group only while it has matches. An InputGroupAddon passed as a child adds a leading icon.
"use client"
import { GlobeIcon } from "lucide-react"
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
} from "@/components/ui/combobox"
import { InputGroupAddon } from "@/components/ui/input-group"
const timezones = [
{
value: "Americas",
items: [
"New York (GMT−5)",
"Chicago (GMT−6)",
"Los Angeles (GMT−8)",
"São Paulo (GMT−3)",
],
},
{
value: "Europe",
items: [
"London (GMT+0)",
"Paris (GMT+1)",
"Berlin (GMT+1)",
"Warsaw (GMT+1)",
],
},
{
value: "Asia–Pacific",
items: [
"Dubai (GMT+4)",
"Singapore (GMT+8)",
"Tokyo (GMT+9)",
"Sydney (GMT+11)",
],
},
]
export default function ComboboxGroups() {
return (
<Combobox items={timezones}>
<ComboboxInput
placeholder="Search time zones…"
aria-label="Time zone"
className="w-72"
>
<InputGroupAddon>
<GlobeIcon />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent>
<ComboboxEmpty>No time zones found.</ComboboxEmpty>
<ComboboxList>
{(group: (typeof timezones)[number], index: number) => (
<ComboboxGroup key={group.value} items={group.items}>
{index > 0 && <ComboboxSeparator />}
<ComboboxLabel>{group.value}</ComboboxLabel>
<ComboboxCollection>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}Multiple selection
Selected values become chips inside the field. useComboboxAnchor returns a ref for the chips container so the list lines up with the whole field rather than the text input, and autoHighlight makes Enter pick the first match while typing.
Press Backspace in an empty input to remove the last label.
"use client"
import * as React from "react"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
const labels = [
"Bug",
"Feature",
"Design",
"Documentation",
"Performance",
"Accessibility",
"Security",
"Good first issue",
]
export default function ComboboxMultiple() {
const anchor = useComboboxAnchor()
return (
<Field className="w-full max-w-sm">
<FieldLabel htmlFor="labels">Labels</FieldLabel>
<Combobox
items={labels}
multiple
autoHighlight
defaultValue={["Design", "Accessibility"]}
>
<ComboboxChips ref={anchor}>
<ComboboxValue>
{(values: string[]) => (
<React.Fragment>
{values.map((value) => (
<ComboboxChip key={value}>{value}</ComboboxChip>
))}
<ComboboxChipsInput
id="labels"
placeholder={values.length ? "" : "Add labels…"}
/>
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No labels match.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>
Press <kbd className="font-sans font-medium">Backspace</kbd> in an empty
input to remove the last label.
</FieldDescription>
</Field>
)
}Rich options
Options can show anything that helps people recognize the right one — an avatar and an email disambiguate two people with the same name. With object values, itemToStringLabel decides what fills the input and itemToStringValue what a form submits.
"use client"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
const people = [
{ name: "Ada Lovelace", email: "ada@analytical.engine", initials: "AL" },
{ name: "Alan Turing", email: "alan@bletchley.park", initials: "AT" },
{ name: "Grace Hopper", email: "grace@cobol.dev", initials: "GH" },
{ name: "Katherine Johnson", email: "katherine@nasa.gov", initials: "KJ" },
{
name: "Margaret Hamilton",
email: "margaret@apollo.mit.edu",
initials: "MH",
},
]
type Person = (typeof people)[number]
export default function ComboboxRichItems() {
return (
<Combobox
items={people}
itemToStringLabel={(person: Person) => person.name}
itemToStringValue={(person: Person) => person.email}
>
<ComboboxInput
placeholder="Invite a teammate…"
aria-label="Teammate"
className="w-80"
/>
<ComboboxContent>
<ComboboxEmpty>No teammates found.</ComboboxEmpty>
<ComboboxList>
{(person: Person) => (
<ComboboxItem key={person.email} value={person} className="py-2">
<Avatar size="sm">
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground">
{person.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{person.email}
</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}Button trigger
When the field should look like a button — a repository switcher, a toolbar filter — render ComboboxTrigger as a Button and move the input into the popup with showTrigger={false}.
"use client"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@/components/ui/combobox"
import { Button } from "@/components/ui/button"
const repositories = [
{ value: "prfct/ui", label: "prfct/ui" },
{ value: "prfct/tokens", label: "prfct/tokens" },
{ value: "prfct/icons", label: "prfct/icons" },
{ value: "prfct/docs", label: "prfct/docs" },
{ value: "prfct/figma-kit", label: "prfct/figma-kit" },
]
export default function ComboboxPopup() {
return (
<Combobox items={repositories} defaultValue={repositories[0]}>
<ComboboxTrigger
aria-label="Repository"
render={
<Button
variant="outline"
className="w-60 justify-between font-normal"
/>
}
>
<ComboboxValue />
</ComboboxTrigger>
<ComboboxContent className="w-60">
<ComboboxInput showTrigger={false} placeholder="Find a repository…" />
<ComboboxEmpty>No repositories found.</ComboboxEmpty>
<ComboboxList>
{(repository: (typeof repositories)[number]) => (
<ComboboxItem key={repository.value} value={repository}>
<span className="font-mono text-code">{repository.label}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}Server-side search
For data that lives on a server, set filter={null} so Base UI stops filtering locally, fetch on onInputValueChange, and pass the results back in as items. Debounce keystrokes, ignore stale responses, and show that a search is in flight — here the search icon becomes a spinner.
"use client"
import * as React from "react"
import { SearchIcon } from "lucide-react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { InputGroupAddon } from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
const cities = [
"Amsterdam",
"Athens",
"Barcelona",
"Berlin",
"Bratislava",
"Brussels",
"Budapest",
"Copenhagen",
"Dublin",
"Gdańsk",
"Helsinki",
"Kraków",
"Lisbon",
"Ljubljana",
"London",
"Madrid",
"Oslo",
"Paris",
"Prague",
"Riga",
"Rome",
"Stockholm",
"Tallinn",
"Vienna",
"Vilnius",
"Warsaw",
"Wrocław",
"Zurich",
]
/** Stands in for a network request. */
function searchCities(query: string) {
return new Promise<string[]>((resolve) =>
setTimeout(() => {
const q = query.trim().toLowerCase()
resolve(
q
? cities.filter((city) => city.toLowerCase().includes(q))
: cities.slice(0, 6)
)
}, 450)
)
}
export default function ComboboxAsync() {
const [query, setQuery] = React.useState("")
const [results, setResults] = React.useState<string[]>([])
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
let active = true
// Debounce keystrokes, then fetch. Ignore responses for stale queries.
const timer = setTimeout(async () => {
setLoading(true)
const next = await searchCities(query)
if (active) {
setResults(next)
setLoading(false)
}
}, 150)
return () => {
active = false
clearTimeout(timer)
}
}, [query])
return (
<Combobox
items={results}
filter={null}
onInputValueChange={(value) => setQuery(value)}
>
<ComboboxInput
placeholder="Search European cities…"
aria-label="City"
className="w-72"
>
<InputGroupAddon>
{loading ? <Spinner label="Searching" /> : <SearchIcon />}
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent aria-busy={loading || undefined}>
<ComboboxEmpty>
{loading ? (
<span className="flex items-center gap-2">
<Spinner aria-hidden="true" /> Searching…
</span>
) : (
"No cities found."
)}
</ComboboxEmpty>
<ComboboxList>
{(city: string) => (
<ComboboxItem key={city} value={city}>
{city}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}Guidelines
When to use
- For long lists — countries, people, time zones, repositories — where typing a few letters is faster than scrolling.
- When people usually know what they're looking for.
- To pick several values from a long list: labels, recipients, tags.
When not to use
- For fewer than about fifteen options people browse rather than search — use a Select.
- For free text with optional suggestions, such as a search box — use an Input with your own suggestion list; a combobox commits to one of its items.
- To run commands — use a Command palette.
Matching
Match anywhere in the label, not only at the start (york should find New York), and ignore case and accents (krakow should find Kraków — pass a locale when your data isn't English). Put the most likely matches first, and keep the empty state specific: say what wasn't found and, when you can, what to do instead.
Accessibility
The input has role="combobox", aria-expanded and aria-autocomplete="list", and controls a listbox popup. Focus stays in the input while the highlight moves through the options, following the ARIA combobox pattern.
| Key | Behavior |
|---|---|
A–Z | Typing filters the list and opens it. |
↓↑ | Opens the list and moves the highlight. At either end, the highlight returns to the input. |
Enter | Chooses the highlighted option. |
Esc | Closes the list and drops the typed query: the input returns to the selected option's label, or empties when nothing is selected. Pressed again with the list closed, it clears the selection. |
Backspace | In a multiple combobox with an empty input, removes the last chip. |
Tab | Moves focus to the next control and closes the list. |
- Label the input. Point a visible
FieldLabelat the input'sid, or usearia-labelin toolbars. - Announce loading. When results come from a server, give the spinner an accessible label (the
Spinnercomponent defaults to Loading) and keep the empty state's text meaningful. - Chips. Each chip's remove button is named after the chip — "Remove Design" rather than just "button" — in the language set by LocaleProvider. Pass
removeLabelwhen the chip's content isn't plain text. Remove buttons stay out of the tab order; keyboard users remove chips with Backspace. - Toggle button. The chevron is labelled "Toggle options" and is skipped by Tab — it's a pointer affordance; the input already opens the list from the keyboard.
- Boundaries and targets. The field uses the
inputtoken for its border (3:1, WCAG 1.4.11); options are at least 32px tall.
API reference
Combobox
The root. Doesn't render an element. Accepts every prop of Base UI's Combobox.Root.
ComboboxInput
The text field, rendered inside an Input Group. Children are added to the group, e.g. an InputGroupAddon with an icon.
ComboboxContent
The positioned popup.
ComboboxList, ComboboxItem, ComboboxEmpty
Groups
ComboboxGroup takes the group's items; ComboboxLabel titles it; ComboboxCollection renders its members with a function; ComboboxSeparator divides groups.
Chips
ComboboxChips is the field for multiple selection (attach the ref from useComboboxAnchor()), ComboboxValue renders the selected values with a function, ComboboxChip shows one value with a remove button (showRemove, default true; removeLabel overrides its accessible name), and ComboboxChipsInput is the text input inside the field.
ComboboxTrigger and ComboboxValue
ComboboxTrigger opens the list from a custom element — use render to make it a Button. ComboboxValue renders the current value's label. ComboboxClear clears the selection; ComboboxInput renders one for you with showClear.