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
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-selectThe 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
| 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). |
renderItem* | (item: Item) => React.ReactNode | — | Renders one row of the list. |
searchLabel* | string | — | Accessible name for the in-panel search field (it has no visible label). |
aria-label | string | — | Accessible 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. |
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>. |
data-slot | string | 'searchable-select' | data-slot for the wrapper, so a wrapper component can carry its own testing/styling hook. |
disabled | boolean | false | Disable the control entirely. |
emptyMessage | string | 'No results found.' | Shown inside the panel when the query matches nothing. |
id | string | — | id 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. |
itemSlot | string | '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. |
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). |
renderValue | ((item: Item) => React.ReactNode) | — | Renders the selected item on the trigger. Defaults to SearchableSelectProps.renderItem. |
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. |
searchPlaceholder | string | 'Search…' | Placeholder text for the in-panel search field. |
value | Item | null | The selected item, or null when nothing is selected (controlled). |
Data attributes and CSS variables on SearchableSelect
| Attribute | Values |
|---|---|
data-placeholder | "" |
data-slot | mirrors 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"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 always carries an explicitaria-label, defaulting to the selected item's label and falling back to the placeholder. - The search field is labelled by the required
searchLabeland focused when the panel opens. - The clear control is an
IconButtonsibling 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 |
| Accessibility | labeled, disabled, expanded, placeholder |
| Visual | default, placeholder, disabled |