Searchable Select
The Select-shaped Combobox preset — a full-width trigger, an in-panel search field, a check on the selected row, and an optional clear control.
- Status
- Since
0.7.0- Accessibility pattern
- APG combobox (dialog popup)
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Searchable Select before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify searchable-selectInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/searchable-selectRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/badge, @vegastack/button, @vegastack/combobox, @vegastack/item, @vegastack/load-more, @vegastack/person-avatar, @vegastack/skeleton, @vegastack/spinner.
Usage
import { SearchableSelect } from "@/components/ui/searchable-select";
const [project, setProject] = React.useState<Project | null>(null);
<SearchableSelect
items={projects}
value={project}
onValueChange={setProject}
itemToKey={(p) => p.id}
itemToStringLabel={(p) => p.name}
searchLabel="Search projects"
placeholder="Select project"
/>;SearchableSelect is the one preset for "a Select, but the list is too long to scroll". It is
controlled by the item itself, not by an id: pass the selected item as value, and
onValueChange hands you back an item (or null when the clear control is used). Give it
isItemEqualToValue when the value is not the same object reference as the entry in items.
Use it instead of restating the recipe: CountrySelect and RegionSelect are both thin wrappers over it, and a third data-fed picker should be too.
Reach for a plain Select when the option set is short and fixed, and for Combobox directly when you need free text, suggestions, or multi-select chips.
Anatomy
Searchable Select exports the picker and PersonOption, the standard person row it draws from
itemToSecondaryLabel: an avatar, then the name on the first line and a smaller muted email on the
second — stacked, never inline. It is the standard for every person list — pickers, filter facets
and menus such as a row's Assign › submenu — and a popup that lists people is at least 18rem wide
(min-w-72) so neither line truncates. Every exported part, with the data-slot
it renders (generated from the canonical source):
Examples
Anatomy
Three parts, and one rule that shapes the layout:
<div className="relative w-full"> // the wrapper
<Combobox.Trigger render={<Button variant="outline" className="w-full pe-9" />}>
<ComboboxValue /> // the selected face, or the muted placeholder
</Combobox.Trigger>
<ComboboxContent className="w-(--anchor-width) p-0">
<ComboboxInput showTrigger={false} /> // the panel-search row, inside the popup
<ComboboxEmpty />
<ComboboxList>{…ComboboxItem per item, check on the selected one…}</ComboboxList>
</ComboboxContent>
{/* chevron, OR the clear control when `clearable` and a value is set */}
</div>The rule: an interactive control may not contain another one, so the clear control can never live inside the trigger button. Both it and the chevron are siblings positioned inside the wrapper, and they share one trailing reserve — so the trigger's text box does not move when a value is set.
The trigger is w-full, like every other form control. Constrain it with a parent
(<div className="max-w-xs">), never with a width of its own; the panel matches the trigger width
through --anchor-width.
Selected and empty
With a value set the trigger shows it and the matching row carries a check; with none it shows the
muted placeholder and carries data-placeholder.
Clearable
clearable swaps the chevron for a clear control while a value is set. It reports null, which is
the same code path every other selection uses.
value: "engg-vegastack-platform"
Inside a Field
Inside a Field the trigger is named by FieldLabel — not by its value —
described by the rendered FieldDescription and FieldError, and marked invalid from the Field.
name submits the selected item's itemToKey with a form, and required blocks the submit while
nothing is selected.
Where the release is cut from.
Inline in a row
For a picker inside a table row or a toolbar, size="sm" matches Select size="sm" and
DatePicker size="sm", and variant="ghost" sizes the trigger to its content with no border at
rest. contentClassName widens the panel past a narrow trigger. Clearing returns focus to the
trigger.
For a picker that lives in a chip row — a customer or project pill on a record — renderTrigger
replaces the whole trigger with your own element, built from the current selection. The field
chrome (border, chevron, clear control) goes with it; the popup, search, keyboard and value
contract stay. The element must hold a ref and take button props.
<SearchableSelect
items={customers}
value={customer}
onValueChange={setCustomer}
itemToKey={(c) => c.id}
itemToStringLabel={(c) => c.name}
aria-label="Customer"
renderTrigger={([selected]) => (
<RecordChip
icon={<Building2 />}
value={selected?.name}
placeholder="No customer"
/>
)}
/>Long values
The trigger is one line and cuts a long value off. For a long user-entered name — a product, a task,
a customer — in a dialog, sheet or popover, pass wrap: the trigger's fixed height becomes a minimum
(one line still measures the same) and the picked name runs onto as many lines as it needs, and a
long option wraps in the list instead of running out of the panel. A long word with no spaces breaks
anywhere. The wrapper writes data-wrap. The person contract's name and email stay on one line.
Rich rows
renderItem draws a row (it defaults to itemToStringLabel); renderValue draws the trigger face
when it should differ. Neither is used for people — see People. Filtering
always runs against itemToStringLabel, so include everything a user might type — an owner, a code,
an alias.
Server search
remote hands filtering to the server: the list shows items as given and never filters them
locally. onSearchChange reports the typed query, loading shows skeleton rows while there
are none yet (two lines with itemToSecondaryLabel) and a small spinner while rows already shown
stay, announcing "Searching…" through the panel's status region, error shows in the panel with a
Try again button (onRetry, or loadMore.onLoadMore), and loadMore pages on scroll: a
sentinel after the last option loads the next page as the reader scrolls or arrows toward it,
with skeleton options while it loads — no button. useAsyncSearch returns exactly these props — spread its result.
leadingItems are listed first and never filtered out ("Me", "Unassigned"). A value that is not
in the current page still shows its label.
Several values and option details
multiple makes value an array: rows toggle, the panel stays open while you pick, the trigger
reads {a}, {b} or {n} selected (countLabel), each option reports aria-selected, and a form
posts every key under name. itemToDescription adds a second line read as the option's
description; itemToDisabledReason disables an option while keeping it reachable, with the reason
as its description. groupBy puts the rows under headings, in first-seen order; groupOrder
lists the headings that come first whatever the rows' order.
Add an option
onCreateOption(query) lets the reader add what they typed. While the search text matches no
option's label exactly (ignoring case and the spaces around it), the list ends with an
"Add “text”" row (createLabel), reached with the arrows and Enter like any option, and the empty
message gives way to it. Choosing it calls onCreateOption with the trimmed text; the option the
promise resolves with becomes the selection, or joins it with multiple. A rejected promise
selects nothing, so report the failure from your handler.
Disabled
People
People have one contract: itemToStringLabel (the name), itemToSecondaryLabel (the email)
and itemToAvatar (the Person). With either of the last two, the component draws each option as
one PersonOption — avatar, name, then a smaller muted email, each exactly once — and the trigger as
the avatar plus the name. The local search matches the name and the email. The selected tick matches
by itemToKey, so options from a server search (fresh objects on each fetch) tick exactly as static
ones do.
This is the only correct usage — pass no renderItem or renderValue:
<SearchableSelect
items={people}
value={owner}
onValueChange={setOwner}
itemToKey={(p) => p.id}
itemToStringLabel={(p) => p.name}
itemToSecondaryLabel={(p) => p.email}
itemToAvatar={(p) => p}
searchLabel="Search people"
/>Never return a PersonOption (or a PersonAvatar) from renderItem alongside the person contract:
the rows would carry two avatars and two emails. The component prevents it — with the person
contract, renderItem and renderValue are ignored and a development build logs one
console.warn naming the ignored prop.
itemToAvatar returns the option's Person (name, image, hue): each row then leads with its
PersonAvatar — the photo, else the initials on the person's hue — and so does a single selection
on the trigger. ListFacet takes the same prop for a people filter.
Inactive member
itemToBadge puts a small muted outline badge right after a person's name — "Inactive" for
someone who has left the workspace but still owns records you can filter by. List active people
first. ListFacet takes the same prop; outside a picker (a DropdownMenuItem, a list of your
own) use <PersonOption badge="Inactive" /> directly.
<ListFacet
label="Recorded by"
items={recorders}
value={recorder}
onValueChange={setRecorder}
itemToKey={(p) => p.id}
itemToStringLabel={(p) => p.name}
itemToSecondaryLabel={(p) => p.email}
itemToBadge={(p) => (p.active ? undefined : "Inactive")}
searchLabel="Search people"
/>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | readonly Item[] | — | The full option list. Passed to the Combobox root so filtering, label resolution and the empty state all work from one source. |
itemToKey* | (item: Item) => string | — | A stable React key for an item. |
itemToStringLabel* | (item: Item) => string | — | The string the search query filters against (and the trigger's fallback accessible name). |
aria-describedby | string | — | Ids of the elements that describe the trigger — an error message outside a Field. |
aria-invalid | boolean | — | Marks the trigger invalid — for a control whose error lives outside a Field (inside a
Field the trigger reads its invalid state from the Field). |
aria-label | string | — | Accessible name for the trigger. Inside a Field the FieldLabel names it. Standalone,
role="combobox" prohibits name-from-content, so a trigger with no label falls back to the
selected item's label, then the placeholder. |
className | string | — | Additional classes merged onto the trigger button. |
clearable | boolean | false | Shows a clear control on the trigger while a value is set, which reports null. |
clearLabel | string | 'Clear selection' | Accessible name for the clear control. |
containerClassName | string | — | Additional classes merged onto the wrapping <div>. |
contentClassName | string | — | Additional classes merged onto the popup panel — e.g. min-w-64 when an inline trigger is
narrower than its options. |
countLabel | ((n: number) => string) | (n) => `${n} selected` | The trigger's text for a multiple selection of more than two. |
createLabel | ((query: string) => string) | (query) => `Add “${query}”` | The add row's text for the search text. |
data-slot | string | 'searchable-select' | data-slot for the wrapper, so a wrapper component can carry its own testing/styling hook. |
defaultSearch | string | — | Open on mount with this search text — the character that opened a cell editor
(type-to-edit). It filters like typed text and reaches onSearchChange once, on mount. |
disabled | boolean | false | Disable the control entirely. |
emptyMessage | string | 'No results found.' | Shown inside the panel when the query matches nothing. |
error | React.ReactNode | — | The last search failed: the message shows in the panel with a "Try again" button. |
groupBy | ((item: Item) => string) | — | Group the rows under headings, in first-seen order. |
groupOrder | readonly string[] | — | Headings listed first, in this order, ahead of the first-seen order of the rest — so a pinned group stays on top even when a leading item belongs to another group. |
id | string | — | id forwarded to the trigger for label association. Inside a Field it is not needed —
the trigger reads its id, label, description and invalid state from the Field. |
isItemEqualToValue | ((a: Item, b: Item) => boolean) | (a, b) => itemToKey(a) === itemToKey(b) | Identity comparison between the value and an entry of items. Defaults to comparing
itemToKey, so the selected tick shows on async/dynamic options (fresh objects per fetch)
exactly as on static ones. |
itemSlot | string | 'searchable-select-item' | data-slot written onto each row. |
itemToAvatar | ((item: Item) => Person | undefined) | — | The person contract, part two: the person an option stands for. Each row (and a single
selection on the trigger) leads with their PersonAvatar — the photo, else initials on their
hue — drawn once by the component; never return a PersonOption from renderItem as well. |
itemToBadge | ((item: Item) => React.ReactNode) | — | A small muted outline badge right after a person option's name, on the same line — e.g.
"Inactive" for someone who is no longer an active member. Needs itemToSecondaryLabel. |
itemToDescription | ((item: Item) => string | undefined) | — | A second line under an option, read as its description. |
itemToDisabledReason | ((item: Item) => string | undefined) | — | Why an option is unavailable. Returning a reason disables the option — it stays reachable and reads the reason as its description. |
itemToSecondaryLabel | ((item: Item) => string | undefined) | — | The person contract, part one: a second, smaller muted line under the name — an email. With
it (or itemToAvatar) each row is drawn as ONE PersonOption — avatar, the
itemToStringLabel name, then this email — and renderItem/renderValue are not used. The
local search matches it as well as itemToStringLabel. |
leadingItems | readonly Item[] | — | Items always listed first and never filtered out — "Me", "Unassigned", recent picks. |
loading | boolean | false | A search is in flight: "Searching…" is announced, and the rows already shown stay. |
loadingLabel | string | "Searching…" | What is announced while loading. |
loadMore | LoadMoreState | — | Keyset paging inside the panel: the next page loads as the reader scrolls (or arrows) toward
the last option, with skeleton options while it loads. loading here is the next-page fetch. |
multiple | Multiple | false | Pick several items. value becomes an array, rows toggle, the panel stays open while you
pick, and the trigger reads "{a}, {b}" or "{n} selected". |
name | string | — | Submits the selection with a surrounding <form> under this name. The submitted value is
the item's itemToKey. |
onCreateOption | ((query: string) => Promise<Item>) | undefined — no add row | Lets the reader add what they typed: while the search text matches no option's label exactly
(ignoring case and surrounding spaces), the list ends with an "Add “text”" row, reached like
any option (arrows, Enter). Choosing it calls this with the trimmed text; the option it
resolves with is then selected (added to the selection with multiple). A rejection selects
nothing — report the failure from here. |
onOpenChange | ((open: boolean) => void) | — | Called when the panel opens or closes. |
onRetry | (() => void) | — | What "Try again" calls when there is no loadMore. |
onSearchChange | ((query: string) => void) | — | Called with the search text as it is typed. |
onValueChange | ((value: SearchableSelectValue<Item, Multiple>) => void) | — | Called with the new selection (null, or [] with multiple, when cleared). |
open | boolean | — | Open state of the panel (controlled). |
placeholder | string | 'Select an option' | Shown on the trigger when nothing is selected. |
ref | React.Ref<HTMLButtonElement> | — | Ref forwarded to the trigger button — the component's focusable host (the panel is portaled). |
remote | boolean | false | The server filters: the list shows items as given and never filters locally. Pair with
onSearchChange, loading, error and loadMore (or spread useAsyncSearch). |
renderItem | ((item: Item) => React.ReactNode) | itemToStringLabel | Renders one row of the list. Not used with the person contract (itemToSecondaryLabel /
itemToAvatar), which draws the row itself; passing both warns in development. |
renderTrigger | ((selected: Item[]) => React.ReactElement) | — | Replace the whole trigger with your own element — a RecordChip, an inline pill — for a
picker that lives in a chip row: return the element to render as the trigger, built from
the current selection. The field chrome (border, chevron, clear control) goes with it; the
popup, search, keyboard and value contract stay the same. The element must be able to hold a
ref and take button props (Base UI merges them in). |
renderTriggerValue | ((selected: Item[]) => string) | — | Replaces the trigger's text for every state, empty included — for a trigger that reads "Status: Open" (ListFacet). It also becomes the trigger's fallback accessible name. |
renderValue | ((item: Item) => React.ReactNode) | renderItem | Renders the selected item on the trigger. Not used with the person contract, whose trigger
reads the avatar and the itemToStringLabel name; passing both warns in development. |
required | boolean | false | Requires a selection before the surrounding <form> submits. |
retryLabel | string | "Try again" | The retry button's label. |
rootRef | React.Ref<HTMLDivElement> | — | Ref forwarded to the wrapping <div>. The wrapper exists because the clear control cannot
live inside the trigger button, so a wrapper component whose own public ref is its root
(RegionSelect) forwards it here rather than adding a second nesting level. |
searchable | boolean | true | Show the in-panel search field. Set false for a short list that needs no filtering; the
panel is then just the options. |
searchLabel | string | "Search" | Accessible name for the in-panel search field (it has no visible label) — "Search people",
never the field's own label, which names the trigger. Inside a Field this name is kept
over the Field label. |
searchPlaceholder | string | 'Search…' | Placeholder text for the in-panel search field. |
size | "default" | "sm" | 'default' | Trigger height: sm (28px) is the inline tier shared with Select size="sm" and
DatePicker size="sm", for table rows and toolbars. |
value | SearchableSelectValue<Item, Multiple> | null | The selected item — or items, with multiple — or null when nothing is selected
(controlled). An item missing from items still shows its label. |
variant | "ghost" | "outline" | 'outline' | ghost is the inline trigger: content width, no border at rest, the border on hover, on
focus and while the panel is open — the same tier as Select variant="ghost". |
wrap | boolean | false | Show the whole selected value: the trigger grows a line at a time from its fixed height instead of cutting the value off, and a long option wraps in the list rather than running out of the panel. For a long user-entered name — a product, a task, a customer — in a dialog, sheet or popover. A long unbroken word breaks anywhere. The person contract's one-line name and email are unchanged. |
Data attributes and CSS variables on SearchableSelect
| Attribute | Values |
|---|---|
data-placeholder | "" |
data-size | mirrors a prop or state value |
data-slot | mirrors a prop or state value |
data-variant | mirrors a prop or state value |
data-wrap | "" |
className lands on the trigger; containerClassName on the wrapper. ref is the trigger button
(the focusable host — the panel is portaled); rootRef is the wrapper, for a component whose own
public ref is its root.
Accessibility
- The trigger is
role="combobox"witharia-expandedandaria-haspopup="dialog"— Base UI's pattern for a Select-style combobox whose input lives inside the panel.role="combobox"prohibits name-from-content, so the trigger is always named: by itsFieldLabelinside aField, by a<label for>pointing atid, or byaria-label. With none of these it falls back to the selected item's label, then the placeholder — never over a label it has. - Outside a
Field,aria-invalidandaria-describedbywire the trigger to an error message. - The search field is named by
searchLabel(default "Search"; name what it searches, "Search people") and focused when the panel opens. Inside aField, the Field's label names the trigger only — the search field keeps its own name. - The clear control is a
Button size="icon-xs"sibling of the trigger with its ownaria-label, so the trigger never nests one interactive control inside another. - Selection is one code path for pointer and keyboard alike: both go through the Combobox root's
onValueChange.
| Key | Action |
|---|---|
| Enter / Space | Open the panel from the trigger. |
| ↑ / ↓ | Move the highlight between matching rows. |
| Enter | Select the highlighted row and close. |
| Esc | Close without changing the selection. |
| Contract | States tested |
|---|---|
| Behaviour | default, open, filtered, empty-results, selected, cleared, disabled, multiple, remote, loading, error, paged, option-disabled-reason |
| Accessibility | labeled, disabled, expanded, placeholder |
| Visual | default, placeholder, disabled |
Do / Don't
Combobox
A filterable, keyboard-navigable listbox behind a text input — type-to-filter, grouped items, an announced empty state, and a multi-select chips mode.
People Picker
The one picker for people and teams — single or multiple, with avatar rows, "(you)", team sizes, skeleton loading and footer actions.