111 / Controls
Combobox
A field that narrows a list as you type, and holds one choice or several as removable chips.
Four at most. Type to narrow the list, or add a label that is not on it yet. Backspace on an empty field takes the last one back.
"use client"
import * as React from "react"
import {
Combobox,
type ComboboxOption,
} from "mischief-ui/combobox"
const startingLabels: ComboboxOption[] = [
{ value: "bug", label: "Bug", group: "Kind" },
{ value: "feature", label: "Feature", group: "Kind" },
{ value: "chore", label: "Chore", group: "Kind" },
{ value: "docs", label: "Docs", group: "Area", keywords: ["writing"] },
{ value: "registry", label: "Registry", group: "Area" },
{
value: "accessibility",
label: "Accessibility",
group: "Area",
keywords: ["a11y"],
},
]
export function ComboboxDemo() {
const [options, setOptions] = React.useState(startingLabels)
const [labels, setLabels] = React.useState(["bug"])
return (
<div className="grid w-full max-w-md gap-3 pb-56">
<Combobox
multiple
max={4}
label="Labels"
placeholder="Search labels"
options={options}
value={labels}
onValueChange={setLabels}
onCreate={(label) => {
const created = { value: label.toLowerCase(), label }
setOptions((current) => [...current, created])
setLabels((current) => [...current, created.value])
}}
/>
<p className="text-muted-foreground text-xs">
Four at most. Type to narrow the list, or add a label that is not on it
yet. Backspace on an empty field takes the last one back.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/comboboximport { Combobox } from "mischief-ui/combobox"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Check, ChevronsUpDown, Plus, X } from "lucide-react" import { cn } from "@/lib/utils" export type ComboboxOption = { value: string label: string description?: string /** Puts the option under a named heading in the list. */ group?: string /** Extra words that should match, without being shown. */Usage
export function Labels() {
const [labels, setLabels] = React.useState(["bug"])
return (
<Combobox
multiple
label="Labels"
options={options}
value={labels}
onValueChange={setLabels}
/>
)
}One choice, or several
The multiple prop decides what the field holds and what it reports. Without it the chosen label sits in the field and picking closes the list. With it each choice becomes a chip and the list stays open, because choosing four labels should not mean opening the same menu four times.
<Combobox options={models} onValueChange={(id) => {}} />
<Combobox multiple options={labels} onValueChange={(ids) => {}} />Because the narrowing reads the prop itself, a variable passed as multiple leaves both shapes possible and will not compile. Pick the mode at the call site, or render the two branches separately.
How typing narrows the list
Options are scored against the query and the best matches come first. Lower is better, and an option matching none of these tiers is dropped rather than ranked last.
| Rank | Match |
|---|---|
0 | The label is exactly the query |
1 | The label starts with the query |
2 | The label contains the query |
3 | The group contains the query |
4 | A keyword contains the query |
5 | The description contains the query |
Keywords are for the words people actually type that are not in the label: an old name, an abbreviation, the noun rather than the verb. Ties keep the order you passed, so an option list already arranged deliberately stays that way.
const labels = [
{ value: "accessibility", label: "Accessibility", group: "Area", keywords: ["a11y"] },
{ value: "registry", label: "Registry", group: "Area" },
]A field showing exactly what was chosen is not treated as a search, so reopening a single-value combobox offers the whole list again instead of the one option already in the field.
Ranking it yourself
When the built-in tiers do not suit, pass rank and score the options yourself: fuzzy matching, a field the component knows nothing about, recent choices first. Lower is a better match, and false drops one.
import { rankComboboxOption } from "mischief-ui/combobox"
<Combobox
options={options}
rank={(option, query) =>
option.recent ? -1 : rankComboboxOption(option, query)
}
/>Options from a server
Filtering an array in the browser is right while the whole set is there. Once options come from a search endpoint, two things change: you need to know what was typed, and the component must stop re-ranking what the server already ordered.
const [query, setQuery] = useState("")
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return setHits([])
const controller = new AbortController()
setLoading(true)
searchPeople(query, { signal: controller.signal })
.then(setHits)
.finally(() => setLoading(false))
return () => controller.abort()
}, [query])
<Combobox
multiple
options={hits}
filter={false}
loading={loading}
onQueryChange={setQuery}
/>While loading, the list says it is searching rather than reporting that nothing matched, because an empty list mid-flight is not an answer. It is marked busy at the same time, so a screen reader is told to wait instead of hearing an empty set.
Headings, and a limit
An option naming a group appears under a heading, and options naming the same group meet under one heading in the order the group first appears. A heading disappears when nothing under it matches, so filtering never leaves an empty section behind.
max caps how many can be held. At the cap the rest go unavailable rather than vanishing, which keeps the list stable and explains why they cannot be picked. What is already chosen stays removable, and the cap is announced when it is reached.
Adding one that is not there
Pass onCreate and the list offers what was typed as a new option, unless a label already matches it or the cap has been reached. The component reports the text and nothing more: you decide what value it gets, whether it is saved, and whether it is selected.
<Combobox
multiple
options={options}
value={labels}
onValueChange={setLabels}
onCreate={(label) => {
const option = { value: slugify(label), label }
setOptions((current) => [...current, option])
setLabels((current) => [...current, option.value])
}}
/>API
optionsComboboxOption[]Each with a value and a label, and optional description, group, keywords, and disabled.multiplebooleanHolds several choices as chips instead of one, and keeps the list open.value, defaultValuestring | string[]Controlled and uncontrolled selection. An array when multiple.onValueChange(value: string | string[]) => voidThe selection after a change. An array when multiple.maxnumberHow many may be chosen. The rest go unavailable once reached. Multiple only.onQueryChange(query: string) => voidCalled as the query changes, for fetching the options yourself.loadingbooleanSays options are on their way.loadingMessageReactNodeShown while loading. Defaults to "Searching".filterbooleanRank and filter here. Turn it off when results arrive already matched.rankComboboxRankerScore an option against the query yourself. Lower is better, false drops it.onCreate(label: string) => voidOffers what was typed as a new option and reports the text.createLabel(query: string) => ReactNodeWhat the create row says.labelstringNames the field and the list. Defaults to "Options".placeholderstringShown while the field is empty.emptyMessage(query: string) => ReactNodeShown when nothing matches.disabledbooleanDisables the field and every chip control.ComboboxOption
valuestringWhat onValueChange reports and value matches.labelstringShown in the list, in the chip, and in the field.descriptionstringA line beneath the label.groupstringPuts the option under a named heading.keywordsstring[]Extra words that should match, without being shown.disabledbooleanListed but unchoosable, and skipped by the keyboard.Accessibility
The field is a combobox that owns the list, says whether it is open, and points at the active option with aria-activedescendant, so focus never leaves the input and nothing is lost between the query and the list. Arrow keys move and step past anything unavailable, Home and End jump, Enter chooses, Escape closes and then clears what was typed, and backspace on an empty field takes the last chip back. The list is marked multi-selectable when it is, busy while options are loading, and its headings name their groups. Every chip control names the option it removes rather than being a row of identical buttons, and additions, removals, and reaching the cap are announced in a polite live region.