Link one or many related records from any collection — a contact to a company, a post to its author, an order to a customer, a task to a project. Async search (debounced, abortable, paged), recent picks, inline create, a browse dialog for big sets, chips or cards, and ids → labels resolution with a deleted-record state. The kit never knows your API: you pass a RecordSource.
The account this contact belongs to.
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 { Field, RecordPicker, type RecordSource } from "@vamos-ui/react";
const companies: RecordSource<Company> = {
key: "companies",
search: (q, { signal, cursor }) => api.companies.search({ q, cursor }, { signal }),
getByIds: (ids, signal) => api.companies.byIds(ids, { signal }),
getId: (c) => c.id,
getLabel: (c) => c.name,
getDescription: (c) => c.domain,
};
<Field label="Company">
<RecordPicker source={companies} value={companyId} onValueChange={setCompanyId} />
</Field>Search on the server should fold like the client: in Postgres, unaccent(lower(name)) with a Turkish collation, so “istanbul” finds “İstanbul” and “cagri” finds “Çağrı”.
Using TanStack Query? Wrap it in search: (q, { signal }) => queryClient.fetchQuery({ queryKey: ['companies', q], queryFn: () => api(q, { signal }) }).
clearRecordPickerCache(key?) drops the session caches (after a logout, in tests).
Examples
Multiple (related posts)
multiple renders chips that link to the record (href) with their own remove button. max disables the rest of the list. Backspace in an empty search removes the last chip. arrayRecordSource searches an in-memory list with locale folding.
Shown under the article. Up to 4.
Create inline
onCreate adds a “Create “query”” row when nothing matches exactly. Return the new record; it is cached and selected.
Type a new name to add it to your vendor list.
Browse dialog & paging
Results page through nextCursor (Load more). browse adds a dialog with a bigger list and checkboxes; pass a function instead to render your own Data Table with filters.
160 customers — search, load more, or open Browse all for a bigger list.
Cards on a detail page
display="card" shows each record on an inset card with avatar, open link and remove. Good for record detail pages (CRM deal, HR employee).
Resolving ids
Only ids in the value? getByIds resolves them (skeleton chips meanwhile). Ids that don't come back show a warning “Unknown record” chip you can remove. Pass selectedRecords to skip the round trip.
Loaded from ids. One project was deleted.
States & sizes
Error, disabled and read-only through Field (read-only chips stay links). size sm for tables and toolbars.
Choose the account that pays for this subscription.
Locked after the contract is signed.
API
RecordPicker
| Prop | Type | Default |
|---|---|---|
sourceHow to search and label the collection (see below). | RecordSource<R> | — |
multiplePick many. Changes value to string[]. | boolean | false |
valueControlled id(s). Empty is null (single) or [] (multi). | string | null · string[] | — |
defaultValueInitial id(s) (uncontrolled). | string | null · string[] | — |
onValueChangeCalled with the new id(s) and the resolved record(s). | (id, record) => void · (ids, records) => void | — |
maxMulti: most records that can be picked. | number | — |
selectedRecordsRecords for the initial value, so labels show without calling getByIds. | R[] | — |
renderItemCustom option row. Default: avatar, label and description with matches in bold. | (r, { selected, disabled, query }) => ReactNode | — |
getDisabledRecords that can't be picked (archived, no access). Already-selected ones still show. | (r) => boolean | — |
getGroupGroup results under headings. | (r) => string | undefined | — |
onCreateAdds a “Create “query”” row. The returned record is selected. | (query) => R | Promise<R> | — |
browsetrue: built-in browse dialog. Function: render your own dialog; call onPick(records). | boolean | (props) => ReactNode | — |
recentShown when the query is empty. Default: the last 5 picks from this source. | R[] | false | session history |
actionsPinned rows on top (“Assign to me”). onSelect gets { select, close, query }. | RecordPickerAction<R>[] | — |
resolveSelectionReplace what a pick adds (a team → its members). | (r) => R | R[] | Promise<…> | — |
displayHow selected records render. | "chip" | "card" | "chip" |
modeinline renders the search list without a trigger (side panels). | "popover" | "inline" | "popover" |
renderTriggerCustom trigger (an avatar stack, a table-cell button). Must forward ref and props. | (ctx) => ReactElement | — |
headerContent above the search box (tabs, filters). | ReactNode | — |
sizeFollows the density axis. | "sm" | "md" | "lg" | "md" |
placeholderTrigger text when empty. | string | "Select…" / "Add…" |
searchPlaceholderSearch box placeholder and accessible name. | string | "Search…" |
clearableSingle: the × clear button. | boolean | true unless required |
nameHidden input(s) for native form posts; multi repeats the name (FormData.getAll). | string | — |
disabled / readOnly / invalid / requiredAlso read from the surrounding Field. | boolean | — |
open / defaultOpen / onOpenChangePopover state. | boolean / (open) => void | — |
labelsOverride built-in strings (dictionary namespace recordPicker). | Partial<RecordPickerLabels> | — |
RecordSource<R>
| Prop | Type | Default |
|---|---|---|
keyCache key for the collection; records are cached per key for the session. | string | — |
searchServer search. Debounced 200 ms and aborted when the query changes. Fold case and accents server-side too. | (query, { signal, cursor, locale }) => Promise<{ items, nextCursor? }> | — |
getByIdsResolve labels for ids you already have. | (ids, signal) => Promise<R[]> | — |
getId / getLabelIdentity and display text. | (r) => string | — |
getDescriptionSecond line (email, domain, SKU). | (r) => string | undefined | — |
getAvatarAvatar for rows, chips and cards. | (r) => { src?, name, shape? } | — |
hrefChips and cards link to the record. | (r) => string | undefined | — |
arrayRecordSource(key, items, options)
| Prop | Type | Default |
|---|---|---|
getId / getLabel / getDescription / getAvatar / hrefAs in RecordSource. | (r) => … | — |
getKeywordsExtra searchable text. | (r) => string[] | — |
pageSizeResults per page. | number | 50 |
delayFake latency in ms (prototypes). | number | 0 |