Select
A flat-props select that covers three modes — plain dropdown, searchable dropdown with fuzzy filtering, and multi-select with chips — through a single component. Built on Base UI's Combobox primitive.
Installation
With the @easy-shadcn namespace configured:
pnpm dlx shadcn@latest add @easy-shadcn/selectOr install via the full URL (zero configuration):
pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/select.jsonThe underlying shadcn combobox primitive and the useSelectItems hook are installed automatically alongside select.
useSelectItems
useSelectItems is a pure derivation hook (no state, no effects) that turns a flat SelectItem[] into the helper data a Combobox needs. It is exported separately so you can build a custom combobox UI on top of components/ui/combobox without duplicating this logic.
import { useSelectItems } from "@/hooks/use-select-items"
const { stringItems, findItem, itemToStringLabel, filterFn } =
useSelectItems({ items })
<Combobox
items={stringItems}
itemToStringLabel={itemToStringLabel}
filter={filterFn}
value={value}
onValueChange={setValue}
>
{/* render with components/ui/combobox primitives directly */}
</Combobox>| Return key | Type | Description |
|---|---|---|
stringItems | string[] | The value of each item — pass to <Combobox items>. |
findItem | (value: string) => SelectItem | undefined | Look up an item by its string value. |
itemToStringLabel | (value: string) => string | Stringify the label of an item. Used for filtering and form submission. |
filterFn | (itemValue: string, query: string) => boolean | Default case-insensitive contains filter, honoring the user-supplied filter override. |
Usage
Plain dropdown
The default mode. A button-style trigger opens a popover with the item list. No search input is rendered.
import { Select } from "@/components/easy/select"
const items = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items} placeholder="Pick a fruit" />Searchable
Pass searchable to render an input that filters the list as the user types. Filtering is case-insensitive contains against label by default.
<Select items={items} searchable clearable placeholder="Search a country" />Multiple
Pass multiple to enable chip-style multi-selection. The input below the chips lets the user keep filtering. searchable is implicitly true in this mode.
const [value, setValue] = useState<string[]>([])
<Select
items={items}
multiple
value={value}
onValueChange={setValue}
/>Custom filter
Override the default contains matching by passing a filter function. It receives the full SelectItem and the current query string.
<Select
items={items}
searchable
filter={(item, query) => item.value.startsWith(query.toLowerCase())}
/>Remote loading (eager)
Pass a loadItems async function instead of items to fetch the option list on demand. By default the loader is invoked once on mount with an empty query; subsequent typing is filtered locally.
Pass loadOn="open" to defer the first request until the popup is opened — useful when the dropdown may never be touched.
const loadCountries = async (_query: string, signal: AbortSignal) => {
const res = await fetch("/api/countries", { signal })
return res.json() as Promise<SelectItem[]>
}
<Select
loadItems={loadCountries}
loadOn="open"
searchable
clearable
placeholder="Search a country"
/>Server-side search
Pass serverSideFilter to drive filtering from the server: every input change re-invokes loadItems(query) (debounced by debounceMs, default 250). The client-side filter is disabled, and already-selected items are merged back into the result list so chips never lose their labels mid-search.
The loader receives an AbortSignal so you can cancel stale requests. The component creates a new signal on every call and aborts the previous one for you — just plumb it through to fetch.
When the fetch rejects, the popup shows an error message and a Retry button.
Selected: —
const searchReviewers = async (query: string, signal: AbortSignal) => {
const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal })
if (!res.ok) {
throw new Error("Failed to search reviewers")
}
return res.json() as Promise<SelectItem[]>
}
<Select
loadItems={searchReviewers}
serverSideFilter
debounceMs={300}
multiple
placeholder="Search reviewers"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | SelectItem[] | - | Static option list. Each item is { label, value, disabled?, itemClassName? }. Ignored when loadItems is provided. |
loadItems | (query: string, signal: AbortSignal) => Promise<SelectItem[]> | - | Async loader. Behavior depends on serverSideFilter. See Remote loading above. |
loadOn | "mount" | "open" | "mount" | When the first eager fetch fires. Ignored when serverSideFilter is true. |
serverSideFilter | boolean | false | If true, every input change re-invokes loadItems(query); client-side filter is disabled. |
debounceMs | number | 250 | Debounce for loadItems calls in serverSideFilter mode. |
loadingMessage | ReactNode | "Loading…" | Shown in the popup while a fetch is pending. |
errorMessage | ReactNode | (error: unknown) => ReactNode | extract | Shown when loadItems rejects. Default extracts error.message / error.error, falls back to "Failed to load options". |
value | string / string[] | - | Controlled value. string in single mode, string[] when multiple is true. |
defaultValue | string / string[] | - | Uncontrolled initial value. Uses the same mode-specific shape as value. |
onValueChange | (value) => void | - | Called with string | undefined in single mode and string[] in multiple mode. |
multiple | boolean | false | Enable multi-select with chips. Implicitly enables searchable. |
searchable | boolean | false | Render an input that filters the list. Ignored (treated as true) when multiple is true. |
clearable | boolean | false | Show a clear button when the field has a value and the right-side icon area is hovered or focused. Works in plain, searchable, and multiple modes. |
placeholder | string | "Pick an option" | Placeholder shown when no value is selected. |
emptyMessage | ReactNode | "No results" | Content rendered when no items match the query. |
disabled | boolean | false | Disable all interaction. |
open | boolean | - | Controlled popover open state. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | - | Called when the popover open state changes. |
filter | (item: SelectItem, query: string) => boolean | contains | Custom filter predicate. Defaults to case-insensitive contains against label. |
name | string | - | Form field name for native form submission. |
className | ClassValue | - | Root wrapper className. |
triggerClassName | ClassValue | - | Trigger / input / chips-container className depending on mode. |
inputClassName | ClassValue | - | <ComboboxInput /> className. Used in searchable and multiple modes. |
contentClassName | ClassValue | - | Popover content className. |
itemClassName | ClassValue | - | Default item className. Merged with each item.itemClassName. |
chipClassName | ClassValue | - | Chip className. Used in multiple mode. |
emptyClassName | ClassValue | - | <ComboboxEmpty /> className. |
Notes
- Single vs multi value type. The TypeScript props are discriminated by
multiple: single mode usesvalue?: stringandonValueChange(value: string | undefined), while multiple mode requiresmultipleand usesvalue?: string[]withonValueChange(value: string[]). Clearing returnsundefinedin single mode and[]in multiple mode. multipleimpliessearchable. A multi-select without an input would be a non-standard UX. Even if you passsearchable={false}alongsidemultiple, the input is still rendered.serverSideFilterimplies an input. A plain dropdown cannot drive a search query. WhenserverSideFilteristrueand neithersearchablenormultipleis set, the component renders the searchable input variant.itemsvsloadItems. They are mutually exclusive — when both are passed,loadItemswins anditemsis ignored.- AbortSignal contract.
loadItems(query, signal)receives a freshAbortSignalper call; the previous request is aborted automatically when a new one starts or when the component unmounts. Pass the signal tofetchto get cancellation for free. - Selected-item cache. In
serverSideFiltermode, the component caches the fullSelectItemof every value that has appeared in a server response. Subsequent searches can return a smaller result set without dropping the chip / trigger label for previously-selected entries. - Default filtering is Base UI's case-insensitive
containsagainst the string returned byitemToStringLabel(which the component implements asString(item.label)). If yourlabelis JSX rather than a string, the default match will be lossy — pass a customfilterin that case. - No grouping in this layer. For grouped options, dividers, the “status” slot in custom shapes, or anything not covered by these props, drop down to the underlying
components/ui/comboboxprimitive and compose<ComboboxGroup>,<ComboboxGroupLabel>,<ComboboxSeparator>yourself. - Controlled vs uncontrolled. Passing a
valueprop makes the select controlled, even when that prop isundefined. Omitvalueentirely to let the component manage its own state viadefaultValue.