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.
import { Combobox } from 'neba';
<Combobox
label="Framework"
placeholder="Search or type your own"
items={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' }
]}
/>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| locale | string | — | BCP 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 |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop 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 |
| multiple | boolean | false | Whether more than one value may be held. The chosen ones become chips inside the field |
| value | string | number | (string | number)[] | null | — | The chosen value: an array when multiple. Use with onValueChange for a controlled combobox |
| defaultValue | string | number | (string | number)[] | null | — | The initial value, for an uncontrolled combobox |
| onValueChange | (value) => void | — | Called when the chosen value changes |
| onInputValueChange | (inputValue: string) => void | — | Called as the text in the input changes: the filter query, not the value |
| filter | false | ((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 |
| allowCustom | boolean | true | Whether 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) => ReactNode | Add “…” | What that row says |
| clearable | boolean | false | Shows a × that empties the field. A field that can be cleared in one click can be emptied by accident |
| emptyMessage | ReactNode | — | Shown in the popup when nothing matches and no value may be added |
| limit | number | -1 | The most rows the list will show at once. -1 is all of them |
| placeholder | string | — | Shown in the input while nothing is typed |
| label | ReactNode | — | The label, wired to the control by Base UI's Field |
| description | ReactNode | — | Helper text |
| error | ReactNode | — | Error message. Its presence turns the control invalid and re-points the colour family at danger |
| invalid | boolean | !!error | Forces the invalid state without a message, for when a form library owns validity |
| startIcon | ReactNode | — | Content before the input |
| fullWidth | boolean | false | Stretches to the width of the container |
| removeLabel | (label: string) => string | Remove … | Accessible name of a chip's remove button. Receives the chip's label |
| clearLabel | string | — | Accessible name of the clear button |
| name | string | — | Identifies the field when a form is submitted |
| readOnly | boolean | false | Shown but not changeable. Keeps its colour and edge, drains the saturation |
| disabled | boolean | false | Unavailable. Drops the colour family for neutral grey |
| shortcuts | NebaShortcuts<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 |
| open | boolean | — | Whether the popup is open. Use with `onOpenChange` for a controlled popup |
| defaultOpen | boolean | — | Whether the popup starts open |
| onOpenChange | (open: boolean) => void | — | Called when the popup opens or closes |
| required | boolean | — | Whether a value must be chosen before the form is submitted |
| inputRef | Ref<HTMLInputElement> | — | A ref to the text input the reader types into |
| classNames | NebaSlots<'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.
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:
<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.
<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.
<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
comboboxrole and the list thelistboxrole;labelbecomes 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
disabledoption stays in the list and reportsaria-disabled. - Each chip's remove button is named through
removeLabel, which receives the chip's own label. localedecides the no-matches line and the names of the clear and remove buttons;emptyMessage,clearLabelandremoveLabelwrite them out instead.