Skip to content

Combobox

A field that filters a list as you type. Use it when there are too many options for a Select, or when a value outside the list has to be accepted.

tsx
import { Combobox } from 'neba';

<Combobox
  label="Framework"
  placeholder="Search or type your own"
  items={[
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' }
  ]}
/>;

Props

PropTypeDefaultDescription
localestringBCP 47 tag: the no-matches line and the clear and remove buttons
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface. The same shell as a TextField, so the two are indistinguishable in a form
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale. With multiple it becomes a minimum, because the field grows as the chips wrap
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. The surface is white, so it reaches the edge, the focus ring and the chips
densityshared'default' | 'compact''default'Padding only: never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth of the field. The popup carries its own
items * readonly ComboboxOption[]The options, as an array of { value, label?, disabled? }: label is a string, not a ReactNode
multiplebooleanfalseWhether more than one value may be held. The chosen ones become chips inside the field
valuestring | number | (string | number)[] | nullThe chosen value: an array when multiple. Use with onValueChange for a controlled combobox
defaultValuestring | number | (string | number)[] | nullThe initial value, for an uncontrolled combobox
onValueChange(value) => voidCalled when the chosen value changes
onInputValueChange(inputValue: string) => voidCalled as the text in the input changes: the filter query, not the value
filterfalse | ((option: ComboboxOption, query: string) => boolean)How the typed text narrows the list. false filters nothing, which is what a list a server has already narrowed needs
allowCustombooleantrueWhether a value the list does not contain may be committed. The typed text is offered as its own row at the end of the list: what separates this from a searchable select
customLabel(query: string) => ReactNodeAdd “…”What that row says
clearablebooleanfalseShows a × that empties the field. A field that can be cleared in one click can be emptied by accident
emptyMessageReactNodeShown in the popup when nothing matches and no value may be added
limitnumber-1The most rows the list will show at once. -1 is all of them
placeholderstringShown in the input while nothing is typed
labelReactNodeThe label, wired to the control by Base UI's Field
descriptionReactNodeHelper text
errorReactNodeError message. Its presence turns the control invalid and re-points the colour family at danger
invalidboolean!!errorForces the invalid state without a message, for when a form library owns validity
startIconReactNodeContent before the input
fullWidthbooleanfalseStretches to the width of the container
removeLabel(label: string) => stringRemove …Accessible name of a chip's remove button. Receives the chip's label
clearLabelstringAccessible name of the clear button
namestringIdentifies the field when a form is submitted
readOnlybooleanfalseShown but not changeable. Keeps its colour and edge, drains the saturation
disabledbooleanfalseUnavailable. Drops the colour family for neutral grey
shortcutsNebaShortcuts<HTMLInputElement>Key combinations to act on, spelled the way Shortcut draws them: { 'Mod+Enter': send }. Mod is Command on a Mac and Control everywhere else. On a Combobox this is the only way in: the arrows, Escape and Enter are the list's keys and never reach an onKeyDown on the root at all. It runs before the list acts, and does not replace what the list does
openbooleanWhether the popup is open. Use with `onOpenChange` for a controlled popup
defaultOpenbooleanWhether the popup starts open
onOpenChange(open: boolean) => voidCalled when the popup opens or closes
requiredbooleanWhether a value must be chosen before the form is submitted
inputRefRef<HTMLInputElement>A ref to the text input the reader types into
classNamesNebaSlots<'label' | 'shell' | 'control' | 'description' | 'error' | 'chip' | 'popup' | 'item'>Class names for the parts behind the root. The root itself is className, so there is no root key

Native <div> attributes pass through to the root. Only color and defaultValue are excluded, since the table above spells them differently.

items

The same array shape Select takes; only label's type differs.

ts
interface ComboboxOption {
  value: string | number;
  label?: string; // a string, not a ReactNode
  disabled?: boolean;
}

It has to be a string because the filter matches against it and the input writes it out.

Examples

multiple

Chosen values appear as Chips inside the field, and the input goes on filtering after each one. Backspace on an empty input moves focus to the last chip.

allowCustom · customLabel · emptyMessage

allowCustom is on by default. The typed text is offered as its own row at the end of the list, so Enter, a click and the arrow keys all reach it the way they reach every other row. It is never committed quietly on blur.

Turn it off with allowCustom={false} for a closed set of values; then emptyMessage is shown when nothing matches.

variant

The same three weights a TextField has, drawn on the same shell.

size

A single-select Combobox is exactly as tall as a TextField of the same size. With multiple the field grows as the chips wrap, so it has no fixed height.

disabled · readOnly · error

clearable · limit

clearable adds a button that empties the value. limit caps how many items the popup shows at once.

filter

By default the typed text narrows the list here, accent- and case-insensitively, against each option's label.

A list the server has already narrowed needs filter={false}. A search that matched on a keyword, a description or a synonym sends back rows whose visible label does not contain the query at all, and filtering them a second time drops exactly the results the search was for. Fetch on onInputValueChange, hand the answer to items, and let it through:

tsx
<Combobox
  items={results}
  filter={false}
  onInputValueChange={(query) => search(query)}
  label="Customer"
/>

A function decides per option instead — matching the value as well as the label, or matching from the start of a word rather than anywhere in it. The row that offers to add what was typed is never filtered out.

The popup

Identical to Select's: portalled to the end of <body>, with neba-portal on the positioner.

shortcuts

On a Combobox this is the only way in. The arrows move the highlight, Escape closes the popup and Enter commits: those keys belong to the list, and they never reach an onKeyDown written on the root at all.

tsx
<Combobox label="Framework" items={frameworks} shortcuts={{ 'Mod+Enter': createAndOpen }} />

Combinations are written the way Shortcut draws them, Mod is Command on a Mac and Control everywhere else, and the modifiers are matched exactly.

It is bound to the <input> and runs before the list acts on the key, but it does not replace what the list does. A shortcut on Enter fires alongside the commit, not instead of it. Bind a combination the list has no opinion about when you need the key to itself.

classNames

className lands on the root (the column holding the label, the shell and the two lines under it), so the <input> is reached through classNames.control.

tsx
<Combobox
  items={frameworks}
  label="Framework"
  multiple
  classNames={{ control: 'font-mono', chip: 'rounded-none', popup: 'max-h-40' }}
/>

The slots are label, shell, control, description, error, chip, popup and item. chip is one token in front of the input in multiple mode. popup and item render at the end of <body>, so nothing written against the root reaches them. See prop conventions for how a class name you pass resolves against the component's own.

Accessibility

  • The trigger has the combobox role and the list the listbox role; label becomes the accessible name.
  • Filtering, the popup's positioning and flipping, arrow-key navigation across both the list and the chips, and the hidden input for form submission are all handled.
  • A disabled option stays in the list and reports aria-disabled.
  • Each chip's remove button is named through removeLabel, which receives the chip's own label.
  • locale decides the no-matches line and the names of the clear and remove buttons; emptyMessage, clearLabel and removeLabel write them out instead.

Released under the MIT License