Gesso
Forms

Combobox

A searchable list in a popover that chooses one value or several for a field.

Combobox chooses a value for a field from a list you search by typing, like an assignee, a project, or a set of labels. It is Command in a Popover under its trigger, and a check marks each chosen row. It holds one value or several, and the value itself lives in your state. For a short list that needs no search, use Select.

Usage

import { Field, FieldLabel } from '@/components/ui/field';
import {
  Combobox,
  ComboboxButton,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/ui/combobox';

<Field>
  <FieldLabel htmlFor="assignee">Assignee</FieldLabel>
  <Combobox>
    <ComboboxTrigger asChild>
      <ComboboxButton id="assignee" placeholder="Assign…">
        {assignee?.name}
      </ComboboxButton>
    </ComboboxTrigger>
    <ComboboxContent>
      <ComboboxInput aria-label="Search people" placeholder="Search people…" />
      <ComboboxList>
        <ComboboxEmpty>No one matches.</ComboboxEmpty>
        <ComboboxGroup heading="People">
          {people.map((person) => (
            <ComboboxItem
              key={person.id}
              value={person.name}
              selected={person.id === assignee?.id}
              onSelect={() => setAssignee(person)}
            >
              {person.name}
            </ComboboxItem>
          ))}
        </ComboboxGroup>
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</Field>

The file exports the nine parts above. ComboboxTrigger is the Popover trigger, so it takes asChild and the button inside it. The rest wrap Command's parts: ComboboxInput is CommandInput, ComboboxList is CommandList, and ComboboxGroup, ComboboxItem, and ComboboxEmpty are Command's group, item, and empty state.

The value is yours

Combobox owns only whether the popover is open and whether a pick closes it. You hold the value, pass selected to each row that holds it, and set it in the row's onSelect. A chosen row shows a check on the right, the way Select and Dropdown menu mark the current value. With one value, a pick runs onSelect, closes the popover, and returns focus to the trigger. Picking the chosen row again runs onSelect again, so your handler decides whether that clears it.

Set value on every row. It is the text the search matches and what onSelect receives, and when it is left out cmdk reads it from the row's text.

Choosing several

multiple keeps the list open on select, so picking three labels is one trip rather than three. Hold an array, pass selected to each row in it, and toggle the row in onSelect. The list is marked aria-multiselectable too.

<Combobox multiple>
  <ComboboxTrigger asChild>
    <ComboboxButton id="labels" placeholder="Add labels…">
      {labels.join(', ')}
    </ComboboxButton>
  </ComboboxTrigger>
  <ComboboxContent>
    <ComboboxInput aria-label="Search labels" placeholder="Search labels…" />
    <ComboboxList>
      {LABELS.map((label) => (
        <ComboboxItem
          key={label}
          value={label}
          selected={labels.includes(label)}
          onSelect={() => toggle(label)}
        >
          {label}
        </ComboboxItem>
      ))}
      <ComboboxItem value="Clear all" closeOnSelect onSelect={() => setLabels([])}>
        Clear all
      </ComboboxItem>
    </ComboboxList>
  </ComboboxContent>
</Combobox>

closeOnSelect on a row overrides the root for that row. It is what a "Clear all" row wants in an otherwise multi-select list, as in the demo, and in a single-select combobox closeOnSelect={false} keeps a row from closing the popover.

To put the combobox behind a chip instead of a button, see Chip trigger, which also covers several values shown as badges.

The trigger

ComboboxButton is an outline Button at sm with a caret on the right. It is full width and 36px tall, which is 4px shorter than an Input or a Select trigger in the same form, and on a touch screen it grows to at least 44px. It shows its children on one line, cut off when they run long, and its placeholder in muted text when the children are empty. It takes Button's props, so size, variant, and disabled change it.

Any button can be the trigger instead, through ComboboxTrigger asChild, which is how a chip opens one.

ComboboxContent opens 4px under the trigger, aligned to its start edge, as wide as the trigger and at least 224px. It has no padding of its own, and the rows sit 4px in from its edge whether or not you wrap them in a group, the same 4px a Dropdown menu row clears. Reach for a group when the rows want a heading, not for spacing. The list scrolls past 256px.

cmdk filters as you type, as in Command. It matches each row's value and keywords, hides the rows that do not match, and sorts the rest best first, and ComboboxEmpty shows when nothing matches. In the first demo each person's team is a keyword, so "design" finds Maya Chen.

ComboboxContent hands its props to the popover and not to cmdk, so shouldFilter, filter, and loop cannot reach the list. For rows that come from a server, put a Command with shouldFilter={false} in a Popover yourself, as Command describes.

Props

Combobox takes Popover's root props, open, defaultOpen, onOpenChange, and modal, plus multiple. Hold open yourself to open it from code, or set defaultOpen to open it on mount and let it keep the state.

PropTypeDefaultWhat it does
multiplebooleanfalseOn Combobox. Keeps the popover open when a row is picked, for a value you hold as an array, and marks the list aria-multiselectable.
selectedbooleanOn ComboboxItem. Shows the check, and describes the row as "Selected" for screen readers.
closeOnSelectbooleanCloses unless multipleOn ComboboxItem. true closes the popover when this row is picked, and false keeps it open.
placeholderstringOn ComboboxButton. Shown in muted text when the button has no children.

The rest take their parts' props. ComboboxContent takes PopoverContent's, with align set to "start". ComboboxInput takes CommandInput's, including leading, trailing, and wrapperClassName. ComboboxList takes cmdk's label, which names the list. ComboboxGroup takes heading. ComboboxItem takes cmdk's value, keywords, disabled, and onSelect, which gets the row's value. ComboboxButton takes Button's.

Accessibility

  • The trigger is a button with the combobox role, aria-haspopup="dialog", and aria-expanded. Point a FieldLabel's htmlFor at the button's id, and the label names it and the button's text is its value, so the first demo reads as "Assignee" with the value "Maya Chen". Without a visible label, give the button an aria-label.
  • Everything in the button is read as the value, so mark decorative pieces like an avatar aria-hidden, as the demos do, or its initials are read with the name.
  • Opening moves focus to the search field, which is a combobox that controls a listbox of option rows and points at the highlighted row with aria-activedescendant. cmdk's hidden label is empty here, so the field's placeholder names it, and an aria-label on ComboboxInput names it instead. The list is named "Suggestions" unless you pass label to ComboboxList.
  • ↑ and ↓ move the highlight, Enter picks the highlighted row, and the rest of Command's keys work too. Opening highlights the first row, not the chosen one.
  • cmdk sets aria-selected on the highlighted row rather than the chosen ones, which is right for one value and wrong for several. A chosen row is described as "Selected" through aria-describedby instead, so a screen reader hears the state without it joining the row's text, and the list is aria-multiselectable when multiple is set.
  • Escape closes the popover and returns focus to the trigger, and so does a pick that closes it. A click outside closes it and leaves focus where you clicked. Tab does not leave the open popover, and the search field is its only stop, so Tab stays there.
  • The popover is a dialog with no name, and it is not modal unless you set modal, so the page behind stays available to assistive tech.
  • ComboboxEmpty is not a live region, so a screen reader is not told when nothing matches.
  • The trigger is 36px tall and at least 44px on a touch screen. Rows are 32px and do not grow on a touch screen.
  • Under reduced motion the popover opens and closes without its fade, and rows never animate.
  • The highlight is only a background wash, which forced colors repaints, so in Windows high contrast the highlighted row has no mark of its own.

When to use something else

  • Select for one value from a short list that needs no search. Its trigger lines up with Input, and inside a <form> its value submits, where Combobox adds nothing to the form.
  • Command to run an action rather than choose a value. Combobox is Command in a Popover that holds a chosen value for a field, where Command runs the highlighted row and holds nothing.

Install

shadcn add @gesso/combobox

It brings Command, Popover, and Button.

On this page