Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
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-select

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/searchable-select

Run 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):

PersonBadge — data-slot="person-badge"
PersonOption — data-slot="person-option"
SearchableSelect

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.

Release 2026.09

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.

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

PropTypeDefaultDescription
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-describedbystring—Ids of the elements that describe the trigger — an error message outside a Field.
aria-invalidboolean—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-labelstring—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.
classNamestring—Additional classes merged onto the trigger button.
clearablebooleanfalseShows a clear control on the trigger while a value is set, which reports null.
clearLabelstring'Clear selection'Accessible name for the clear control.
containerClassNamestring—Additional classes merged onto the wrapping <div>.
contentClassNamestring—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-slotstring'searchable-select'data-slot for the wrapper, so a wrapper component can carry its own testing/styling hook.
defaultSearchstring—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.
disabledbooleanfalseDisable the control entirely.
emptyMessagestring'No results found.'Shown inside the panel when the query matches nothing.
errorReact.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.
groupOrderreadonly 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.
idstring—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.
itemSlotstring'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.
leadingItemsreadonly Item[]—Items always listed first and never filtered out — "Me", "Unassigned", recent picks.
loadingbooleanfalseA search is in flight: "Searching…" is announced, and the rows already shown stay.
loadingLabelstring"Searching…"What is announced while loading.
loadMoreLoadMoreState—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.
multipleMultiplefalsePick several items. value becomes an array, rows toggle, the panel stays open while you pick, and the trigger reads "{a}, {b}" or "{n} selected".
namestring—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 rowLets 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).
openboolean—Open state of the panel (controlled).
placeholderstring'Select an option'Shown on the trigger when nothing is selected.
refReact.Ref<HTMLButtonElement>—Ref forwarded to the trigger button — the component's focusable host (the panel is portaled).
remotebooleanfalseThe 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)itemToStringLabelRenders 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)renderItemRenders 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.
requiredbooleanfalseRequires a selection before the surrounding <form> submits.
retryLabelstring"Try again"The retry button's label.
rootRefReact.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.
searchablebooleantrueShow the in-panel search field. Set false for a short list that needs no filtering; the panel is then just the options.
searchLabelstring"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.
searchPlaceholderstring'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.
valueSearchableSelectValue<Item, Multiple>nullThe 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".
wrapbooleanfalseShow 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

AttributeValues
data-placeholder""
data-sizemirrors a prop or state value
data-slotmirrors a prop or state value
data-variantmirrors 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" with aria-expanded and aria-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 its FieldLabel inside a Field, by a <label for> pointing at id, or by aria-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-invalid and aria-describedby wire 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 a Field, 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 own aria-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.
KeyAction
Enter / SpaceOpen the panel from the trigger.
↑ / ↓Move the highlight between matching rows.
EnterSelect the highlighted row and close.
EscClose without changing the selection.
ContractStates tested
Behaviourdefault, open, filtered, empty-results, selected, cleared, disabled, multiple, remote, loading, error, paged, option-disabled-reason
Accessibilitylabeled, disabled, expanded, placeholder
Visualdefault, placeholder, disabled

Do / Don't

Do
Let the parent size it — the trigger is w-full and the panel follows the anchor width.
Don't
Give the trigger a fixed width; it is the one thing that breaks the control at 320px.
Do
List people with itemToStringLabel, itemToSecondaryLabel and itemToAvatar alone — the component draws the avatar, name and email once.
Don't
Return a PersonOption or PersonAvatar from renderItem as well; it is ignored with a development warning.

On this page