Qure UI
Components

Combobox

A text input that filters a list and remembers what was chosen — for lists too long to scroll.

'use client'import {  Combobox, ComboboxClear, ComboboxContent, ComboboxEmpty, ComboboxInput,  ComboboxInputGroup, ComboboxItem, ComboboxList, ComboboxTrigger,} from '@/registry/qure/ui/combobox'import { Label } from '@/registry/qure/ui/label'/** * `items` is not optional here the way it is on Select. It is the list the * root filters — a Combobox with static children and no `items` renders every * option no matter what is typed. */const protocols = [  'CT chest, high resolution',  'CT chest, pulmonary angiogram',  'CT head, non-contrast',  'CT abdomen and pelvis, portal venous',  'Chest X-ray, PA and lateral',  'MRI brain, with and without contrast',  'MRI lumbar spine',  'Ultrasound abdomen, complete',]export default function ComboboxDemo() {  return (    <div className="flex w-72 flex-col gap-2">      <Label htmlFor="protocol">Protocol</Label>      <Combobox items={protocols}>        <ComboboxInputGroup>          <ComboboxInput id="protocol" placeholder="Search protocols" />          <ComboboxClear />          <ComboboxTrigger />        </ComboboxInputGroup>        <ComboboxContent>          <ComboboxEmpty>No protocol matches that.</ComboboxEmpty>          <ComboboxList>            {(protocol: string) => (              <ComboboxItem key={protocol} value={protocol}>{protocol}</ComboboxItem>            )}          </ComboboxList>        </ComboboxContent>      </Combobox>    </div>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/combobox.json

Usage

import {
  Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput,
  ComboboxInputGroup, ComboboxItem, ComboboxList, ComboboxTrigger,
} from '@/components/ui/combobox'
const protocols = ['CT chest, high resolution', 'CT head, non-contrast']

<Combobox items={protocols}>
  <ComboboxInputGroup>
    <ComboboxInput placeholder="Search protocols" />
    <ComboboxTrigger />
  </ComboboxInputGroup>
  <ComboboxContent>
    <ComboboxEmpty>No protocol matches that.</ComboboxEmpty>
    <ComboboxList>
      {(protocol: string) => (
        <ComboboxItem key={protocol} value={protocol}>{protocol}</ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxContent>
</Combobox>

items is not decoration. It is the array the root filters, and it is what ComboboxEmpty counts to decide whether the list is empty. A combobox written with static ComboboxItem children and no items renders every option no matter what is typed — nothing errors, the filter simply does not exist. Drive the list from items and a function child, as above.

Reach for a combobox when the list is long enough that scrolling it is worse than typing at it — protocols, referrers, sites, ICD codes. Under about a dozen options a Select is faster, because the reader can see all of them without committing to a search term. When any text at all is valid and the list is only a suggestion, you want Autocomplete; when the list is a set of actions rather than values, you want Command.

Composition

<Combobox>                 {/* owns the value, the query and the open state */}
  <ComboboxInputGroup>     {/* the field shell — carries the border */}
    <ComboboxInput>        {/* the query; bare inside the group */}
    <ComboboxClear>        {/* appears once there is something to clear */}
    <ComboboxTrigger>      {/* the chevron, opens without typing */}
    <ComboboxChips>        {/* multi-select only; the input goes inside it */}
      <ComboboxChip>
  </ComboboxInputGroup>
  <ComboboxContent>        {/* portal + positioner + popup */}
    <ComboboxStatus>       {/* "Searching…", announced politely */}
    <ComboboxEmpty>        {/* "No match", announced politely */}
    <ComboboxList>         {/* the rows */}
      <ComboboxGroup>
        <ComboboxGroupLabel>
        <ComboboxCollection>
      <ComboboxItem>       {/* renders its own tick */}
  </ComboboxContent>
</Combobox>

ComboboxContent bundles the portal, the positioner and the popup, but deliberately not the list: ComboboxEmpty and ComboboxStatus are siblings of the list, not children of it. Both are live regions and have to stay mounted to be announced, so conditionally render the text inside them rather than the elements themselves.

Choosing several

1 selected

'use client'import * as React from 'react'import {  Combobox, ComboboxChip, ComboboxChips, ComboboxContent, ComboboxEmpty,  ComboboxInput, ComboboxInputGroup, ComboboxItem, ComboboxList, ComboboxValue,} from '@/registry/qure/ui/combobox'import { Label } from '@/registry/qure/ui/label'const findings = [  'Pulmonary nodule',  'Consolidation',  'Pleural effusion',  'Pneumothorax',  'Cardiomegaly',  'Hilar lymphadenopathy',  'Rib fracture',  'Atelectasis',]export default function ComboboxMultiple() {  const [value, setValue] = React.useState<string[]>(['Pleural effusion'])  return (    <div className="flex w-80 flex-col gap-2">      <Label htmlFor="findings">Findings</Label>      <Combobox items={findings} multiple value={value} onValueChange={setValue}>        <ComboboxInputGroup>          {/* The input sits inside Chips, not beside it: the caret has to end              up after the last chip when the field wraps to a second line. */}          <ComboboxChips>            <ComboboxValue>              {(selected: string[]) => (                <React.Fragment>                  {selected.map(finding => (                    <ComboboxChip key={finding} aria-label={finding}>{finding}</ComboboxChip>                  ))}                  <ComboboxInput                    id="findings"                    placeholder={selected.length ? '' : 'Add a finding'}                  />                </React.Fragment>              )}            </ComboboxValue>          </ComboboxChips>        </ComboboxInputGroup>        <ComboboxContent>          <ComboboxEmpty>Nothing matches that.</ComboboxEmpty>          <ComboboxList>            {(finding: string) => (              <ComboboxItem key={finding} value={finding}>{finding}</ComboboxItem>            )}          </ComboboxList>        </ComboboxContent>      </Combobox>      <p className="text-muted-foreground text-sm">        {value.length ? `${value.length} selected` : 'None selected'}      </p>    </div>  )}

multiple turns the value into an array and each row into a toggle. The chips are the Base UI anatomy rather than ours: ComboboxChips lives inside ComboboxInputGroup, and the input lives inside the chips, because the caret has to end up after the last chip when the field wraps.

Give each chip an aria-label — the remove button next to it otherwise announces only "Remove".

Groups

'use client'import {  Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup,  ComboboxGroupLabel, ComboboxInput, ComboboxInputGroup, ComboboxItem,  ComboboxList, ComboboxTrigger,} from '@/registry/qure/ui/combobox'import { Label } from '@/registry/qure/ui/label'/** * Grouped items are `{ value, items }`. The root recognises the shape, filters * inside each group and drops the groups that end up empty — so a search for * "spine" leaves only the Musculoskeletal heading behind. */const byRegion = [  { value: 'Thorax', items: ['CT chest, high resolution', 'CT pulmonary angiogram', 'Chest X-ray, PA'] },  { value: 'Neuro', items: ['CT head, non-contrast', 'CT angiogram, circle of Willis', 'MRI brain'] },  { value: 'Musculoskeletal', items: ['MRI lumbar spine', 'MRI cervical spine', 'X-ray knee, weight bearing'] },]export default function ComboboxGroups() {  return (    <div className="flex w-80 flex-col gap-2">      <Label htmlFor="region-protocol">Protocol</Label>      <Combobox items={byRegion}>        <ComboboxInputGroup>          <ComboboxInput id="region-protocol" placeholder="Filter by name, e.g. spine" />          <ComboboxTrigger />        </ComboboxInputGroup>        <ComboboxContent>          <ComboboxEmpty>No protocol matches that.</ComboboxEmpty>          <ComboboxList>            {(group: { value: string; items: string[] }) => (              <ComboboxGroup key={group.value} items={group.items}>                <ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>                <ComboboxCollection>                  {(protocol: string) => (                    <ComboboxItem key={protocol} value={protocol}>{protocol}</ComboboxItem>                  )}                </ComboboxCollection>              </ComboboxGroup>            )}          </ComboboxList>        </ComboboxContent>      </Combobox>    </div>  )}

Grouped items are { value, items }. The root recognises the shape, filters within each group and drops the groups that end up empty, so searching "spine" leaves one heading rather than three with a gap under two of them.

Searching a server

'use client'import * as React from 'react'import {  Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxInputGroup,  ComboboxItem, ComboboxList, ComboboxStatus,} from '@/registry/qure/ui/combobox'import { Label } from '@/registry/qure/ui/label'const directory = [  'Dr A. Mehta — Respiratory',  'Dr S. Iyer — Cardiology',  'Dr P. Nair — Emergency',  'Dr R. Bhatt — Respiratory',  'Dr K. Rao — Oncology',]/** Stands in for the search endpoint. */function search(query: string) {  return new Promise<string[]>(resolve => {    setTimeout(() => {      const q = query.trim().toLowerCase()      resolve(q ? directory.filter(name => name.toLowerCase().includes(q)) : [])    }, 450)  })}export default function ComboboxAsync() {  const [query, setQuery] = React.useState('')  const [results, setResults] = React.useState<string[]>([])  const [loading, setLoading] = React.useState(false)  React.useEffect(() => {    if (!query.trim()) {      setResults([])      setLoading(false)      return    }    setLoading(true)    let live = true    const timer = setTimeout(() => {      search(query).then(found => {        if (!live) return        setResults(found)        setLoading(false)      })    }, 200)    return () => {      live = false      clearTimeout(timer)    }  }, [query])  return (    <div className="flex w-80 flex-col gap-2">      <Label htmlFor="referrer">Referring clinician</Label>      {/*        `filter={null}` hands filtering to the server. Leaving it on would run        the local matcher over results that have already been matched, and a        server that fuzzy-matches "meta" to "Mehta" would have its answer        thrown away.      */}      <Combobox items={results} filter={null} onInputValueChange={setQuery}>        <ComboboxInputGroup>          <ComboboxInput id="referrer" placeholder="Type at least two letters" />        </ComboboxInputGroup>        <ComboboxContent>          {/* Stays mounted so its changes are announced. Conditionally render              the text inside it, never the element itself. */}          <ComboboxStatus>{loading ? 'Searching the directory…' : null}</ComboboxStatus>          <ComboboxEmpty>            {loading ? null : query.trim() ? 'No clinician by that name.' : 'Start typing to search.'}          </ComboboxEmpty>          <ComboboxList>            {(name: string) => <ComboboxItem key={name} value={name}>{name}</ComboboxItem>}          </ComboboxList>        </ComboboxContent>      </Combobox>    </div>  )}

Pass filter={null} when the results already came back filtered. Leaving the local matcher on runs it a second time over the server's answer, and a server that fuzzily matched "meta" to "Mehta" has that work thrown away.

ComboboxStatus is the right place for "Searching…" — it is a polite live region, so a screen reader hears the wait rather than being left with an unexplained silence.

Sizes

'use client'import {  Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxInputGroup,  ComboboxItem, ComboboxList, ComboboxTrigger,} from '@/registry/qure/ui/combobox'import { Label } from '@/registry/qure/ui/label'const modalities = ['CT', 'CR', 'DX', 'MR', 'US', 'NM', 'PT', 'XA']const sizes = [  { size: 'sm', label: 'Small — 32px, for dense toolbars' },  { size: 'md', label: 'Medium — 40px, the default' },  { size: 'lg', label: 'Large — 48px, for touch' },] as constexport default function ComboboxSize() {  return (    <div className="flex w-72 flex-col gap-5">      {sizes.map(({ size, label }) => (        <div key={size} className="flex flex-col gap-2">          <Label htmlFor={`modality-${size}`}>{label}</Label>          <Combobox items={modalities}>            <ComboboxInputGroup>              <ComboboxInput id={`modality-${size}`} size={size} placeholder="Modality" />              <ComboboxTrigger />            </ComboboxInputGroup>            <ComboboxContent>              <ComboboxEmpty>No such modality.</ComboboxEmpty>              <ComboboxList>                {(modality: string) => (                  <ComboboxItem key={modality} value={modality}>{modality}</ComboboxItem>                )}              </ComboboxList>            </ComboboxContent>          </Combobox>        </div>      ))}    </div>  )}

The 32/40/48 field scale, shared with Input and Select, so a combobox and a text field on the same row line up. Pass size to ComboboxInput; the group takes its height from whatever input is inside it.

How state reads

Read off the rendered DOM rather than assumed:

.cn-combobox-item[data-highlighted] { background-color: var(--background-brand-tertiary); }
.cn-combobox-item[data-selected]    { font-weight: 600; }

data-highlighted is the cursor — keyboard and pointer both, kept to one row at a time by the primitive. data-selected is the chosen value and survives the popup closing. The trigger gets data-popup-open, which is what rotates the chevron.

Keyboard

Observed, not assumed: typing filters and opens the list; ArrowDown from the input moves into it and wraps back to the input past the last row; Enter takes the highlighted row; Escape closes the list and leaves the value alone; Backspace in an empty multi-select input removes the last chip.

The chevron is a real ComboboxTrigger, not a decorative icon, so the list can be opened without typing. A reader who does not know the vocabulary needs to browse before they can search.

API Reference

Everything Base UI's Combobox accepts. What we add on ComboboxInput:

Prop

Type

On ComboboxContent:

Prop

Type

On Combobox itself, the ones you will actually reach for:

Prop

Type

On this page