Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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

Add Searchable Select from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

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

The same command installs the registry items it composes: @vegastack/combobox, @vegastack/button, @vegastack/icon-button.

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}
  renderItem={(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.

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">
    <ComboboxPopupInput />          // the panel-search row: leading icon, hairline below
    <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"

Rich rows

renderItem draws a row; renderValue draws the trigger face when it should differ. Filtering always runs against itemToStringLabel, so include everything a user might type — an owner, a code, an alias.

Disabled

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) => stringA stable React key for an item.
itemToStringLabel*(item: Item) => stringThe string the search query filters against (and the trigger's fallback accessible name).
renderItem*(item: Item) => React.ReactNodeRenders one row of the list.
searchLabel*stringAccessible name for the in-panel search field (it has no visible label).
aria-labelstringAccessible name for the trigger. role="combobox" prohibits name-from-content, so the control always needs one; defaults to the selected item's label, falling back to the placeholder.
classNamestringAdditional 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.
containerClassNamestringAdditional classes merged onto the wrapping <div>.
data-slotstring'searchable-select'data-slot for the wrapper, so a wrapper component can carry its own testing/styling hook.
disabledbooleanfalseDisable the control entirely.
emptyMessagestring'No results found.'Shown inside the panel when the query matches nothing.
idstringid forwarded to the trigger for label association.
isItemEqualToValue((a: Item, b: Item) => boolean)Identity comparison between the value and an entry of items. Defaults to reference equality.
itemSlotstring'searchable-select-item'data-slot written onto each row.
onOpenChange((open: boolean) => void)Called when the panel opens or closes.
onValueChange((value: Item | null) => void)Called with the newly selected item, or null when the clear control is used.
openbooleanOpen 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).
renderValue((item: Item) => React.ReactNode)Renders the selected item on the trigger. Defaults to SearchableSelectProps.renderItem.
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.
searchPlaceholderstring'Search…'Placeholder text for the in-panel search field.
valueItemnullThe selected item, or null when nothing is selected (controlled).

Data attributes and CSS variables on SearchableSelect

AttributeValues
data-placeholder""
data-slotmirrors a prop or state value

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 always carries an explicit aria-label, defaulting to the selected item's label and falling back to the placeholder.
  • The search field is labelled by the required searchLabel and focused when the panel opens.
  • The clear control is an IconButton 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
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.

On this page