Mischief

111 / Controls

Combobox

A field that narrows a list as you type, and holds one choice or several as removable chips.

Bug

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.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/combobox
import { Combobox } from "mischief-ui/combobox"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/combobox/combobox.tsx
"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) => {}} />
id is a string in the first, and an array of them in the second. TypeScript narrows on multiple, so neither call site casts.

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.

RankMatch
0The label is exactly the query
1The label starts with the query
2The label contains the query
3The group contains the query
4A keyword contains the query
5The 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" },
]
Typing a11y finds the first even though no label says it.

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)
  }
/>
The built-in ranker is exported, so yours can defer to it rather than reproduce it.

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}
/>
Debouncing and aborting stay yours: only you know what the endpoint costs.

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])
  }}
/>
Reporting the text rather than inventing a value keeps ids yours.

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.