Combobox

A text field that filters a list of options as you type.

A text field that filters a list of options as you type. The difference from Select is where the typing happens: a Select is a button that opens a list, and its optional filter lives inside that list once it is open. A Combobox is the field itself — you are already typing when the options appear, which is what you want when the value is something you know the name of rather than something you expect to recognise by scrolling.

Installation

Combobox ships with the library — no separate install.

import { Combobox, Label } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add combobox

Usage

<Combobox
  value={framework}
  onValueChange={setFramework}
  placeholder="Search frameworks"
  clearable
>
  <Combobox.Item value="expo" label="Expo" />
  <Combobox.Item value="next" label="Next.js" />
</Combobox>

Composition

<Combobox>
  <Combobox.Item value="…" label="…" />
  <Combobox.Group label="…">
    <Combobox.Item value="…" label="…" />
  </Combobox.Group>
</Combobox>
  • Combobox.Item — One option. value identifies it, label is what is shown and what the filter matches on, description adds a second line, start draws something before the label, and disabled keeps it listed but unselectable.
  • Combobox.Group — A titled run of options. Presentational only — a grouped Combobox reports the same values a flat one would.

Examples

Filter as you type

The list narrows on every keystroke; clearable offers a ✕ that empties the field.

const [framework, setFramework] = useState<string>();

<Combobox
  value={framework}
  onValueChange={setFramework}
  placeholder="Search frameworks"
  clearable
>
  {frameworks.map((f) => (
    <Combobox.Item key={f.value} value={f.value} label={f.label} />
  ))}
</Combobox>

Grouped options

A group is dropped when the query empties it, rather than leaving its heading behind.

<Combobox value={framework} onValueChange={setFramework} openOnFocus>
  <Combobox.Group label="Native">
    <Combobox.Item value="expo" label="Expo" />
    <Combobox.Item value="react-native" label="React Native" />
  </Combobox.Group>
  <Combobox.Group label="Web">
    <Combobox.Item value="next" label="Next.js" />
    <Combobox.Item value="remix" label="Remix" />
  </Combobox.Group>
</Combobox>

Several at once

Each pick becomes a removable chip in front of the input, and the list stays open.

const [picked, setPicked] = useState<string[]>(['expo']);

<Combobox
  mode="multiple"
  value={picked}
  onValueChange={setPicked}
  placeholder="Add a framework"
  clearable
>
  {frameworks.map((f) => (
    <Combobox.Item key={f.value} value={f.value} label={f.label} />
  ))}
</Combobox>

A tag field

allowCustomValue keeps whatever was typed, so the list only suggests.

<Combobox
  mode="multiple"
  value={tags}
  onValueChange={setTags}
  allowCustomValue
  placeholder="Add a tag"
>
  <Combobox.Item value="design" label="design" />
  <Combobox.Item value="engineering" label="engineering" />
</Combobox>

Fetched for the query

The server does the matching, so the field does not filter again — it only shows what came back.

const [query, setQuery] = useState('');
const { results, loading } = useCitySearch(query);

<Combobox
  value={city}
  onValueChange={setCity}
  inputValue={query}
  onInputValueChange={setQuery}
  filter={false}
  loading={loading}
  loadingMessage="Looking up cities"
  emptyMessage="No city by that name"
  placeholder="Search cities"
>
  {results.map((c) => (
    <Combobox.Item key={c.value} value={c.value} label={c.label} />
  ))}
</Combobox>

Expanding in place

inline pushes what is below the field down instead of covering it.

<Combobox
  presentation="inline"
  value={framework}
  onValueChange={setFramework}
  placeholder="Search frameworks"
>
  {/* …items… */}
</Combobox>

Variants

itemDisabled

  • true
<Combobox itemDisabled="true">…</Combobox>

presentation

  • overlay (default)
  • inline
<Combobox presentation="overlay">…</Combobox>
<Combobox presentation="inline">…</Combobox>

API Reference

Combobox.Item

PropTypeDefaultDescription
valuestring
labelstring
disabledbooleanShows the option but refuses it. Kept in the list rather than dropped from it, because an option that vanishes reads as one that never existed.
startReactNodeAnything to draw before the label — an avatar, a flag, a status dot.
descriptionstringA second line under the label, for what the label alone cannot say.

Combobox.Group

PropTypeDefaultDescription
labelstringHeading over the run of options. Announced as a header, so a screen reader reaching the group is told what it is before walking into it.
classNamestringExtra classes for the group wrapper.
labelClassNamestringExtra classes for the heading.

Combobox

PropTypeDefaultDescription
classNamestring
modeModeOne value or several. multiple draws the chosen options as removable chips in front of the input and keeps the list open between picks.
valueComboboxSelection[Mode]Controlled selection. Its shape follows mode.
defaultValueComboboxSelection[Mode]Starting selection when uncontrolled.
onValueChange(value: ComboboxSelection[Mode]) => void
inputValuestringControlled query — the text actually in the field. Pair it with onInputValueChange when the options are fetched for it.
defaultInputValuestring''Starting query when uncontrolled.
onInputValueChange(value: string) => void
placeholderstring'Search'
disabledbooleanfalse
presentationComboboxPresentation'overlay'Where the options appear.
filterboolean | ((option: ComboboxItemProps, query: string) => boolean)trueNarrow the options to the query here. true matches case-insensitively on any part of an option's label; pass a function to match on something else — a description, an alias list, an initialism. Pass false when a server is doing the matching: the options you render are then shown exactly as given, since a second filter over results the field cannot see the query behind would only remove correct answers.
allowCustomValuebooleanfalseLet the typed text become the value when it matches no option, committed on submit. Turns the list into a set of suggestions rather than the set of legal answers — which is what a tag field is.
loadingbooleanfalseShow a spinner in place of the list. For options still being fetched.
emptyMessagestring'No matches'Shown in place of the list when nothing matches.
loadingMessagestring'Searching'Shown in place of the list while loading.
clearablebooleanfalseOffer a ✕ that clears the query and the selection.
openOnFocusbooleanfalseOpen the list as soon as the field takes focus, before anything is typed.
onOpenChange(open: boolean) => voidCalled when the list opens or closes.
contentWidth'field' | 'content' | number'field'Width of the floating list. field matches the field, content sizes to the longest option, or pass a pixel value. overlay only.
offsetnumber8Gap between the field and the floating list. overlay only.
listClassNamestringExtra classes for the list surface.
accessibilityLabelstringAccessible name for the field.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Choosing a presentation

overlay (the default) floats the list through a portal, anchored under the field and flipped above it when the keyboard leaves no room below. Nothing else on the screen moves. contentWidth and offset apply here only.

inline expands the list in normal layout flow instead, pushing what is below it down. Reach for it in a form where nothing should be covered.

There is deliberately no sheet presentation. A sheet takes the bottom of the screen, which is where the keyboard already is, and the field you are typing into would end up behind one or the other. Select can offer a sheet because its trigger stops mattering once the list is open; a Combobox's never does.

One value or several

mode="multiple" draws the chosen options as removable chips in front of the input and keeps the list open between picks, so several can be taken in a row. The value is an array there and a single string otherwise — the type follows mode, so nothing has to be cast.

Picking an already-chosen option removes it, which is the only way to undo a pick without reaching for its chip. Backspace on an empty field takes the last chip back: the same reflex that deletes a character, extended to the thing in front of the cursor when there is no character left to delete.

Filtering, and turning it off

Filtering happens in the component by default, matching case-insensitively on any part of an option's label. Pass a function to filter to match on something else — a description, an alias list, an initialism.

Pass filter={false} when the options come from a server that is doing the matching itself. The field then renders exactly what it was given, because a second filter over results it cannot see the query behind would only remove correct answers. Pair it with the controlled inputValue / onInputValueChange so the query can drive the request, and with loading so the list says it is working rather than saying there are no matches.

Values it does not know about

allowCustomValue lets the typed text become the value when it matches no option, committed on return. That turns the list into a set of suggestions rather than the set of legal answers, which is what a tag field is.

Accessibility

The field is announced as a combobox that owns an expandable list, so the chips and the input read as parts of one control rather than as loose siblings, and the open state is announced on a role where it means something. The floating list is a modal layer: it takes the Android back button and hides the page behind it from the accessibility tree, so a screen reader cannot walk out of an open list into content it is covering.

On this page