---
title: "Combobox"
description: "A text input that narrows a list the product supplies as the reader types, then lets them choose one match."
url: "https://opsinjs.pensievelabs.org/components/combobox"
source: "https://opsinjs.pensievelabs.org/components/combobox.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["autocomplete", "typeahead", "search select"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="combobox"
  status="shipped"
  questions="[
  &#x22;The filter is Base UI's default contains match, so an accented character, an abbreviation or a near-miss spelling may filter a real item away.&#x22;,
  &#x22;Nobody has listened to this in a screen reader, so whether the list narrowing and the empty line are announced is argued, not heard.&#x22;,
  &#x22;No contrast pair is measured and forced colours are unhandled, so the hairline, popup, highlight and tick are argued to survive rather than checked.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="combobox" />

## Installation [#installation]

<ComponentInstall name="combobox" unbuilt="false" importPath="@/components/ui/combobox" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" />

## Usage [#usage]

```tsx
import { Combobox } from "@/components/ui/combobox"
```

```tsx
<Combobox
  label="Medication"
  placeholder="Search medications"
  value={medication}
  onValueChange={setMedication}
  items={[
    { value: "amoxicillin", label: "Amoxicillin" },
    { value: "ibuprofen", label: "Ibuprofen" },
    { value: "paracetamol", label: "Paracetamol" },
  ]}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;One item from a list long enough that typing a few letters beats scrolling it: a city, a country, a product-owned medication name.&#x22;,
  &#x22;A single choice where the reader knows roughly what they want and would rather type than hunt.&#x22;,
  &#x22;A field that must stay compact, filtering a long list in place rather than pushing the form down.&#x22;,
]"
  avoid="[
  { case: &#x22;A short fixed set that fits in a closed menu, so typing to filter buys nothing.&#x22;, instead: &#x22;select&#x22; },
  { case: &#x22;Two to about five short options worth comparing at a glance.&#x22;, instead: &#x22;radio-group&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="combobox"
  parts="[
  {
    name: &#x22;Combobox&#x22;,
    describes: &#x22;The visible root: a neutral hairline box holding the input and the trigger.&#x22;,
    prop: &#x22;items&#x22;,
  },
  {
    name: &#x22;Combobox.Input&#x22;,
    describes: &#x22;The text input and the one tab stop, role=\&#x22;combobox\&#x22;, named by label, filtering by contains match.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Combobox.Trigger&#x22;,
    describes: &#x22;A trailing button that opens the popup without typing, a lucide ChevronsUpDown.&#x22;,
  },
  {
    name: &#x22;Combobox.Popup&#x22;,
    describes: &#x22;The floating list, portalled past overflow clipping, matched to the input's width.&#x22;,
  },
  {
    name: &#x22;Combobox.Item&#x22;,
    describes: &#x22;One match, role=\&#x22;option\&#x22; with aria-selected. The active row takes the neutral hover surface.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;Combobox.ItemIndicator&#x22;,
    describes: &#x22;The lucide Check on the chosen match, so the selection survives greyscale.&#x22;,
  },
  {
    name: &#x22;Combobox.Empty&#x22;,
    describes: &#x22;The muted line shown when nothing matches. Base UI keeps it mounted and announces it politely.&#x22;,
    prop: &#x22;emptyMessage&#x22;,
  },
]"
/>

<CompositionTree
  name="combobox"
  tree="[
  {
    part: &#x22;Combobox&#x22;,
    cardinality: &#x22;1&#x22;,
    children: [
      { part: &#x22;Combobox.Input&#x22;, cardinality: &#x22;1&#x22; },
      { part: &#x22;Combobox.Trigger&#x22;, cardinality: &#x22;1&#x22; },
      {
        part: &#x22;Combobox.Popup&#x22;,
        cardinality: &#x22;1&#x22;,
        children: [
          { part: &#x22;Combobox.Empty&#x22;, cardinality: &#x22;1&#x22; },
          {
            part: &#x22;Combobox.Item&#x22;,
            cardinality: &#x22;0..n&#x22;,
            children: [{ part: &#x22;Combobox.ItemIndicator&#x22;, cardinality: &#x22;1&#x22; }],
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Filtering a list [#filtering-a-list]

`items` supplies a fictional set of cities and typing narrows it. `value` starts
on one city, so the input is filled and the tick is shown.

<ComponentPreview name="combobox-filtering-a-list" kind="example" align="start" />

### No match [#no-match]

Type something the list does not contain, such as "xyz". `emptyMessage` fills the
popup, announced politely.

<ComponentPreview name="combobox-no-match" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Name the field for the thing chosen, "Medication" rather than "Search", since a
screen-reader user hears the name before the role. Write `emptyMessage` to help,
such as "No matches. Check the spelling." Spell item labels as the product's list
spells them.

<DoDont>
  <DoDont.Do>
    **A label of "Medication", a placeholder of "Search medications".** The field
    names the thing, the prompt the act.
  </DoDont.Do>

  <DoDont.Dont>
    **A label of "Search" and a built-in list of medicines.** opsinjs ships no
    vocabulary.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA** in a source pass and a rendered pass, author-run
rather than independent. Clinical review is pending.

The audit fixed two defects in the source: the trigger button now takes an
`aria-label` built from the field's label, and the input's stray `outline-none`
no longer hides the keyboard focus ring.

* Base UI renders `role="combobox"` on the input, `role="listbox"` on the popup
  and `role="option"` with `aria-selected` on each match.
* The input is the single tab stop; Arrow keys move a highlight through the popup.
* The selection is a lucide Check and the highlight a neutral surface under
  `data-highlighted`, so both survive greyscale.
* The input and the trigger floor a 44pt target in rem and carry their own
  focus ring.

**Known gaps.** The label reaches a screen reader as `aria-label`, not a visible
label element; a shared repair across the other input wrappers is still open.
The live match count is not announced as the reader filters. No contrast pair is
measured, so the report below stands in.

<KeyboardTable
  name="combobox"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the control, onto the input&#x22;,
    notes: &#x22;The one tab stop. A second Tab reaches the trigger.&#x22;,
  },
  {
    keys: &#x22;Typing&#x22;,
    action: &#x22;Filters the list to the matches&#x22;,
    notes: &#x22;Contains match on the label. The popup opens as you type.&#x22;,
  },
  {
    keys: &#x22;Arrow Down&#x22;,
    action: &#x22;Opens the popup and moves the highlight into the list&#x22;,
    notes: &#x22;Opens a closed popup and highlights the first match.&#x22;,
  },
  {
    keys: &#x22;Arrow Up, Arrow Down&#x22;,
    action: &#x22;Moves the highlight between matches&#x22;,
    notes: &#x22;The popup holds the highlight while the input keeps the caret.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Chooses the highlighted match and closes the popup&#x22;,
    notes: &#x22;The label fills the input and onValueChange reports the value.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes the popup without choosing&#x22;,
    notes: &#x22;The input keeps its text.&#x22;,
  },
]"
/>

<ContrastReport component="combobox" />

## API reference [#api-reference]

<PropsTable name="ComboboxProps" />

`value` and `onValueChange` make this controlled, with no selection state of its
own. A `value` matching no item renders nothing chosen and raises a development
warning. `label` is required, and `items` comes from the product. Omitted,
`emptyMessage` falls back to a plain line.

## Related [#related]

* [Select](./select.mdx) opens a closed menu of a fixed list with no text filter.
* [RadioGroup](./radio-group.mdx) shows two to five options side by side.
* [Field](./field.mdx) is the labelled wrapper a Combobox sits inside.
