ComponentsPickers

Record picker

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.

Radix Popover + WAI-ARIA combobox / listbox components/record-picker.tsx, components/avatar.tsx, lib/text.tstheme · material · accent · density · font

The account this contact belongs to.

Installation

$ pnpm add @vamos-ui/react

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).

Deal · DL-2207

Lumen Architects — Annual retainer

Primary contact

Selin Arslan
Head of Operations · selin@lumen.studio

Also in the loop

Marcus Lee
Finance Director · marcus@lumen.studio

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.

Unknown recordUnknown record

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.

Harbor & Pine LegalFjord Freight

API

RecordPicker

PropTypeDefault
source
How to search and label the collection (see below).
RecordSource<R>—
multiple
Pick many. Changes value to string[].
booleanfalse
value
Controlled id(s). Empty is null (single) or [] (multi).
string | null · string[]—
defaultValue
Initial id(s) (uncontrolled).
string | null · string[]—
onValueChange
Called with the new id(s) and the resolved record(s).
(id, record) => void · (ids, records) => void—
max
Multi: most records that can be picked.
number—
selectedRecords
Records for the initial value, so labels show without calling getByIds.
R[]—
renderItem
Custom option row. Default: avatar, label and description with matches in bold.
(r, { selected, disabled, query }) => ReactNode—
getDisabled
Records that can't be picked (archived, no access). Already-selected ones still show.
(r) => boolean—
getGroup
Group results under headings.
(r) => string | undefined—
onCreate
Adds a “Create “query”” row. The returned record is selected.
(query) => R | Promise<R>—
browse
true: built-in browse dialog. Function: render your own dialog; call onPick(records).
boolean | (props) => ReactNode—
recent
Shown when the query is empty. Default: the last 5 picks from this source.
R[] | falsesession history
actions
Pinned rows on top (“Assign to me”). onSelect gets { select, close, query }.
RecordPickerAction<R>[]—
resolveSelection
Replace what a pick adds (a team → its members).
(r) => R | R[] | Promise<…>—
display
How selected records render.
"chip" | "card""chip"
mode
inline renders the search list without a trigger (side panels).
"popover" | "inline""popover"
renderTrigger
Custom trigger (an avatar stack, a table-cell button). Must forward ref and props.
(ctx) => ReactElement—
header
Content above the search box (tabs, filters).
ReactNode—
size
Follows the density axis.
"sm" | "md" | "lg""md"
placeholder
Trigger text when empty.
string"Select…" / "Add…"
searchPlaceholder
Search box placeholder and accessible name.
string"Search…"
clearable
Single: the × clear button.
booleantrue unless required
name
Hidden input(s) for native form posts; multi repeats the name (FormData.getAll).
string—
disabled / readOnly / invalid / required
Also read from the surrounding Field.
boolean—
open / defaultOpen / onOpenChange
Popover state.
boolean / (open) => void—
labels
Override built-in strings (dictionary namespace recordPicker).
Partial<RecordPickerLabels>—

RecordSource<R>

PropTypeDefault
key
Cache key for the collection; records are cached per key for the session.
string—
search
Server search. Debounced 200 ms and aborted when the query changes. Fold case and accents server-side too.
(query, { signal, cursor, locale }) => Promise<{ items, nextCursor? }>—
getByIds
Resolve labels for ids you already have.
(ids, signal) => Promise<R[]>—
getId / getLabel
Identity and display text.
(r) => string—
getDescription
Second line (email, domain, SKU).
(r) => string | undefined—
getAvatar
Avatar for rows, chips and cards.
(r) => { src?, name, shape? }—
href
Chips and cards link to the record.
(r) => string | undefined—

arrayRecordSource(key, items, options)

PropTypeDefault
getId / getLabel / getDescription / getAvatar / href
As in RecordSource.
(r) => …—
getKeywords
Extra searchable text.
(r) => string[]—
pageSize
Results per page.
number50
delay
Fake latency in ms (prototypes).
number0