Lookup
Picks records from a search, one in the field or several as pills. Searches records you pass, or a search you run.
Installation
The CLI installs the Radix or Base UI file to match your project, with what it builds on: combobox, icon, input-group, pill, spinner.
If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.
When to use
Use it
- People pick a Salesforce record for a relationship field, like the account on a contact or the record a task is related to.
- People pick several records, like the accounts on a campaign. Pass
multiple, and the records show as pills below the field. - The records come from a search you run as people type, like a GraphQL or REST query. Pass
onSearch. - The records are already in the page, like a short list of recent records. Pass
recordsalone, and the lookup filters them by name. - It replaces
lightning-record-pickerand Salesforce's own lookup fields: object icons, a meta line, the picked record in the field, and several records as pills.
Don't use it
- The options are plain values, not records with an icon and a second line. Use Combobox.
- The list is short and people don't need to type. Use Select.
- The value is any text. Use Input.
- You need to search several objects from one field, like accounts or contacts. Put a Select for the object next to the lookup.
Usage
The value is the record itself, not its id, so the field can show a record the current results don't hold. Hold it in state to control it:
"use client"
import { useState } from "react"
import { Lookup, type LookupRecord } from "@/components/wisty/lookup"export function AccountLookup({ accounts }: { accounts: LookupRecord[] }) {
const [account, setAccount] = useState<LookupRecord | null>(null)
return (
<Lookup
aria-label="Account"
records={accounts}
icon="standard:account"
value={account}
onValueChange={setAccount}
/>
)
}onSearch gets the trimmed term and an AbortSignal, and returns a promise of records. The lookup calls it from two characters, 300ms after typing stops, and aborts the signal when a newer search starts. Pass the signal on, so an aborted request stops on the network too:
async function searchAccounts(
term: string,
{ signal }: { signal: AbortSignal }
) {
const response = await fetch(`/api/accounts?q=${encodeURIComponent(term)}`, {
signal,
})
const rows: { Id: string; Name: string; BillingCity: string }[] =
await response.json()
return rows.map((row) => ({
id: row.Id,
name: row.Name,
meta: `Account • ${row.BillingCity}`,
}))
}In a form, name submits the record's id, or one id per record with multiple.
In Radix projects, Lookup is built on Combobox, which uses @base-ui/react, so installing it adds that package beside radix-ui. The two work side by side.
Examples
Selected
An account set with `value`, shown in the field with its icon and a remove button.
Multiple
Several accounts as pills below the field, set with `multiple`, and the selected ones drop out of the results.
Search
Recently viewed accounts from `records` before you type, then an `onSearch` search with a spinner and the term in bold.
States
A `required` lookup with an error set by `aria-invalid`, and a `disabled` one with a selected contact.
In a dialog
A Related to field in a Log a call dialog: accounts take the lookup's `icon`, and an opportunity and a case set their own on the record.
API reference
Lookup
| Prop | Type | Default | Description |
|---|---|---|---|
multiple | false | true | false | One record, shown in the field. |
records | readonly LookupRecord[] | [] | Records to show. Without onSearch they're filtered by name as people type. |
onSearch | LookupSearch | Finds records for a term of two characters or more; its signal aborts when a newer search starts. | |
icon | IconName | The object icon for records that don't name their own. | |
value | LookupRecord | null | readonly LookupRecord[] | ||
defaultValue | LookupRecord | null | readonly LookupRecord[] | ||
onValueChange | (value: LookupRecord | null) => void | (value: LookupRecord[]) => void | ||
disabled | boolean | false | |
required | boolean | false |
Also takes every <input> (opens in a new tab) prop.
IconNametype
type IconName = React.ComponentProps<typeof Icon>["name"]LookupRecordtype
| Member | Type | Default | Description |
|---|---|---|---|
id* | string | Unique id, like a Salesforce record id. Selection compares ids. | |
name* | string | The record's name: in results, in the field and in pills. | |
meta | string | A second line under the name in results, like "Account • Austin, TX". | |
icon | IconName | The object icon, like "standard:account". Defaults to the lookup's icon. | |
disabled | boolean | Shown in results, but can't be picked. |
LookupSearchtype
type LookupSearch = (
term: string,
options: { signal: AbortSignal }
) => Promise<readonly LookupRecord[]>SLDS mapping
The lookup is SLDS's entity autocomplete combobox. The list is slds-dropdown slds-dropdown_fluid, and each result shows an object icon, the name and a meta line. A picked record sits in the field with its icon and a remove button. Several records are Pills below the field, in a group without its border. A disabled record takes slds-icon_disabled on its icon.
Known gaps:
- No "Search for 'term'" row, no object switcher (
slds-combobox-group) and no collapsed selection group with "+N more". - While a search runs, SLDS shows a remove button for the typed text beside the spinner. The lookup keeps its search icon.
- The search icon and the remove X are lucide's, where SLDS draws
utility:searchandutility:close. - The text the lookup adds ("Remove selected option", "Selected records", "No results for…", "Loading results", "… results", "Something went wrong. Try again.") is English.
| Prop | Value | SLDS | Notes |
|---|---|---|---|
(no value) | default (added) | slds-combobox_container, slds-combobox__input, slds-input__icon_right | A search field with the search icon on the right. |
records | (results) (added) | slds-dropdown_length-with-icon-7, slds-listbox__option_entity, slds-listbox__option-meta | Each result is an object icon, the name and a meta line. The list shows seven before it scrolls. |
onSearch | (set) (added) | mark (in slds-dropdown), slds-input__spinner, slds-spinner_brand | Searches from two characters, 300ms after typing stops, with the brand spinner in the field. The typed term is bold in each name. |
value | (a record) (added) | slds-has-selection, slds-combobox__input-entity-icon, slds-combobox__input-value, slds-input__icon_right (remove) | The record in a read-only field with its object icon and a remove button. |
multiple | true (added) | slds-listbox_selection-group slds-is-expanded, slds-listbox_horizontal, slds-pill | Selected records as pills below the field, always expanded. |
disabled | true (added) | slds-input[disabled], slds-button_icon[disabled] | |
aria-invalid | true (added) | slds-has-error, slds-combobox_container[invalid] |
Keyboard interactions
| Keys | Action |
|---|---|
| Typing | Filters the records, or searches from two characters, and opens the list when there's something to show. |
| Down Arrow, Up Arrow | Opens the list, or moves the highlight. |
| Enter | Picks the highlighted record and closes the list. Without multiple, the field shows it. With multiple, it adds a pill and clears the text. |
| Escape | Closes the list. On a closed field, clears the typed text. Inside a modal dialog, the first Escape closes only the list. |
| Backspace, Delete | On a field showing a record, removes the record. Focus stays in the field, ready to search. |
With multiple, the pills are the next tab stop. Left and Right Arrow move between them, and Backspace or Delete removes one. When the last pill goes, focus returns to the field.
Accessibility
- Name the input with a
FieldLabelwhosehtmlForis the lookup'sid, or passaria-label. - For an error, set
aria-invalidand pointaria-describedbyat the message. In Cosmos dark the error text reaches about 4.3:1, below WCAG AA's 4.5:1. SLDS's own tokens do the same. - In Lightning Blue, a highlighted record's meta line is 4.15:1, below WCAG AA's 4.5:1. SLDS's own stylesheet does the same.
- While the list is open, the rest of the page is hidden from screen readers, the field's label included. The input keeps its name.
Motion
- The spinner's dots bounce while a search runs, and hold still with reduced motion.
Responsive behavior
- The lookup fills its parent's width, and the list is as wide as the field. Set the width on the parent or the Field.
- Long names and meta lines are cut off with an ellipsis. Each name has a
titlewith the full text.
Performance
- The list renders every record it's given. Keep
recordsand search results to tens, and let people narrow a search by typing.
Rules
- Use
multiplefor several records. Never build the pills yourself. - Never pass
asChildorrender.