Searchable selection — single or multiple, static or server-backed, optionally creatable: country, assignee, labels, category. Search folds locale characters (“istanbul” finds “İstanbul”, “cagri” finds “Çağrı”) and highlights matches by weight. Multi-select shows removable chips; async search is debounced, abortable and paged through useAsyncList.
Used for tax on your invoices.
Installation
The whole kit as one package — get updates by bumping the version. Add the styles to globals.css: @import "@vamos-ui/react/styles.css";
Usage
import { Combobox, Field } from "@vamos-ui/react";
<Field label="Country">
<Combobox name="country" items={countries} clearable />
</Field>
<Combobox multiple items={labels} defaultValue={["bug"]} maxVisibleChips={3} />
<Combobox
loadItems={(q, signal, cursor) => fetch(`/api/people?q=${q}&after=${cursor ?? ""}`, { signal }).then((r) => r.json())}
getValue={(p) => p.id}
getLabel={(p) => p.name}
/>WAI-ARIA combobox + listbox: the search box owns aria-activedescendant; ↑/↓ move, Enter selects, Esc closes and returns focus to the trigger.
The result count is announced politely while you type. Async loading sets aria-busy on the list; errors show a retry button.
Matches are emphasised with weight (font-semibold), not the accent colour, so they stay readable on glass.
Examples
Multiple with chips (helpdesk)
The list stays open while you pick. Chips are Badges with a remove button; ←/→ move between chips, Backspace or Delete removes one, and maxVisibleChips collapses the rest into +N.
- Bug
- Billing
- Urgent
- 1 more selected
Backspace in the search box removes the last label.
Server search with paging
loadItems is debounced (200 ms), aborts the superseded request and caches per query. Return { items, cursor } and the list loads the next page on scroll or via “Show more results”. selectedItems supplies labels for initial values.
Results come from the server, 8 at a time — scroll for more.
Create on the fly (CMS)
onCreate shows “Create “…”” for unknown text. Return the new item (or a promise of it) and it is selected.
Type a new name to create a category.
81 provinces, accent-free search
Under tr-TR the search folds İ/ı and ç ğ ö ş ü, so “sanliurfa” and “IGDIR” match. Plate codes are keywords.
Try “istanbul”, “sanliurfa”, “igdir” or a plate code like 35.
Avatar and email rows (CRM)
renderItem draws the row; the highlighted label comes in as state.highlight. keywords make email and company searchable.
Search by name, email or company.
Inline list (permissions)
mode="inline" renders the search and list in place — for settings panels and side sheets. Named by the surrounding Field.
API
Combobox
| Prop | Type | Default |
|---|---|---|
itemsStatic items, filtered client-side (prefix > word start > contains, locale-folded). | T[] | — |
loadItemsServer search. Debounced, aborted when superseded, cached per query, paged with cursor. | (query, signal, cursor?) => Promise<T[] | { items, cursor }> | — |
debouncems before loadItems runs after typing. | number | 200 |
multipleSelect several values; the trigger shows chips. | boolean | false |
value / defaultValueSelected value(s). Controlled when value is not undefined. | string | null · string[] (multiple) | — |
onValueChangeCalled with the value(s) and the matching item(s). | (value, item) => void · (values, items) => void | — |
getValue / getLabelRead any item shape. | (item: T) => string | item.value / item.label |
getKeywords / getDisabled / getGroupExtra search text, disabled rows, group headings. | (item: T) => … | item.keywords / .disabled / .group |
renderItemCustom row content; the check indicator is added for you. | (item, { selected, query, highlight }) => ReactNode | — |
onCreateAdds a “Create “…”” row. Return the item to select it. | (query) => T | void | Promise<T | void> | — |
selectedItemsItems for initial values not in the current list (async). | T[] | — |
modeFloating list under a trigger, or search + list in place. | "popover" | "inline" | "popover" |
clearable× button to clear the selection. | boolean | false |
maxVisibleChipsmultiple: collapse further chips into +N (announced as “N more selected”). | number | — |
closeOnSelectClose the popover after picking. | boolean | true single · false multiple |
placeholder / searchPlaceholderTrigger text with no selection · search box placeholder (also its accessible name). | string | — |
emptyMessageShown when nothing matches. | ReactNode | "No results" |
size / variantSame box as Input and Select. | "sm" | "md" | "lg" · "default" | "ghost" | "pill" | "md" · "default" |
loadingStatic items are still loading (spinner + aria-busy). | boolean | — |
nameHidden input(s); multiple submits one entry per value (formData.getAll). | string | — |
disabled / readOnly / invalid / requiredAlso inherited from Field. | boolean | — |
open / defaultOpen / onOpenChangeControl the popover. | boolean / (open) => void | — |
className / contentClassName / listClassNameRoot box · popover panel · list (max height). | string | — |
labelssearch, empty, create, results, error, retry, loadMore, clear, remove, more… | Partial<ComboboxLabels> | — |
useAsyncList
| Prop | Type | Default |
|---|---|---|
loadThe loader. Absent = idle. | (query, signal, cursor?) => Promise<T[] | { items, cursor }> | — |
queryEach change starts a debounced first-page request; the previous one is aborted. | string | — |
debounce / enabled / cache / minQueryLengthTiming, pause (e.g. while closed), per-query cache, minimum query length. | number / boolean / boolean / number | 200 / true / true / 0 |
→ returnsState for a custom list (Record picker, User picker, Tag input suggestions). | { items, loading, loadingMore, error, hasMore, loadedQuery, loadMore(), reload() } | — |