Combobox

A recipe, not a package namespace. useCombobox owns open, value, input, highlight, and ids. Components own chrome. There is no Combobox.* export. One interactive role per root.

Presentation

useCombobox({ presentation: 'auto' }) picks a sheet on a coarse pointer or a narrow viewport and a popover otherwise, always a popover on the server and the first paint, through useSheetPresentation. Force popover or sheet when a docs screenshot must stay still.

If presentation flips while the list is open, the hook closes with blur.

The badge shows what useSheetPresentation picks here; isSheet is not isTouch.

popover · isSheet=false

Popover · single

Sibling Label + Field.Root id. The input is the combobox. The list lives in Popover.Root.

const cb = useCombobox({ options, presentation: 'popover' })

<div className="labelled-field">
  <Label htmlFor={cb.ids.input}>Framework</Label>
  <Field.Root {...cb.getAnchorProps()}>
    <Field.TextInput {...cb.getInputProps()} />
    <Field.Icon><Chevron /></Field.Icon>
  </Field.Root>
</div>
<Popover.Root {...cb.getPopoverProps()}>
  <OptionList.Root {...cb.getListboxProps()}>
    {cb.items.map((item) => (
      <OptionList.Option key={item.id} {...cb.getOptionProps(item)} />
    ))}
  </OptionList.Root>
</Popover.Root>
React
Redux
Svelte

Popover · multi

Field.ChipInput via getChipInputProps. Chips are labels. Values dedupe.

design
design
tokens

Sheet

The closed control is Button.Root. Search Field lives inside the sheet. Same inputValue and list as popover.

const cb = useCombobox({ options, presentation: 'sheet' })

<Button.Root {...cb.getTriggerProps()}>{selected}</Button.Root>
<BottomSheet.Root {...cb.getSheetProps()}>
  <Field.Root id={cb.ids.input} aria-label="Search">
    <Field.TextInput {...cb.getInputProps()} />
  </Field.Root>
  <OptionList.Root {...cb.getListboxProps()}>…</OptionList.Root>
</BottomSheet.Root>

Auto

One hook. The tree flips with the presentation. Force presentation when you need a still frame.

presentation: popover (popover)

React
Redux
Svelte

Async

loadOptions(query, signal) loads options. A newer query aborts the last controller and discards a stale resolve. The hook keeps the previous list while loading is true. Debounce, loading, and Retry chrome stay in the recipe. The hook does not debounce.

Type fail to see the error row.

Empty and create

No matches render a status row. allowCreate adds one Create "{query}" option in the same OptionList when the query has no case-insensitive label match. Click or Enter commits. Single selects and closes. Multi adds a chip and stays open. onCreate may return a canonical value and should append the new option to options so the list can show it selected.

todae
tokens
todae
tokens
todae
tokens

Highlight matches

useHighlight marks the query in each option without changing the options' markup. Pass the hook's query (empty while the input shows the selected label), put the ref on the list, and style the name with ::highlight(search).

const found = useHighlight({ name: 'search', query: cb.query })

<OptionList.Root ref={found.ref} {...cb.getListboxProps()}>…</OptionList.Root>
San Francisco
San Jose
Santa Clara

Composition rules

  • Sibling Label + Field.Root id. No Field.Label.
  • Do not spread usePopover getTriggerProps next to getAnchorProps. Dual-wire is a QA fail.
  • OptionList is the overlay list. Do not also render Radio or Checkbox for the same value.
  • Do not bake combobox into Field. Do not export Combobox.* chrome.
  • Sheet trigger uses aria-haspopup="dialog". Popover Field chrome has no aria-haspopup.

Hooks

When to use them, and their props, are on useCombobox and useSheetPresentation.