Installation
npx gbs-add-block -a Combobox -betaThe block copies the combobox folder into your project, along with the small shared folder that every component imports. You own the code and can change it freely.
There are no peer dependencies other than React.
Requirements
- React 19 and
@types/react19 - TypeScript target ES2022 or newer, with
"jsx": "react-jsx" - Browsers from 2024 or newer (the styles use
light-dark(),:has()and the Popover API)
Import the stylesheet once, for example in your global CSS:
@import "../components/combobox/styles.css";or in your root layout / entry file:
import "@/components/combobox/styles.css";Select picks one value and MultiSelect picks several, shown as tags. Both are comboboxes: a control that opens a searchable list of options. They share one engine, one stylesheet and one set of keyboard rules, and they support options held in the browser or fetched from an API. The list renders in the browser's top layer, so it is never clipped by a scrolling parent — including inside a DataGrid cell.
Select
MultiSelect
Quick Start
"use client";
import { useState } from "react";
import {
MultiSelect,
Select,
type ComboboxOption,
} from "@/components/combobox";
const countries: ComboboxOption[] = [
{ value: "in", label: "India", group: "Asia", keywords: ["bharat"] },
{ value: "jp", label: "Japan", group: "Asia" },
{ value: "de", label: "Germany", group: "Europe", description: "Berlin" },
{ value: "mx", label: "Mexico", group: "Americas", disabled: true },
];
export default function Example() {
const [country, setCountry] = useState<string | null>(null);
const [tags, setTags] = useState<string[]>([]);
return (
<>
<Select
label="Country"
options={countries}
value={country}
onChange={setCountry}
/>
<MultiSelect
label="Countries"
options={countries}
value={tags}
onChange={setTags}
max={3}
/>
</>
);
}Both components are controlled with value + onChange, or uncontrolled with defaultValue. onChange also receives the matching option objects:
<Select onChange={(value, option) => console.log(value, option?.label)} />
<MultiSelect onChange={(values, options) => console.log(values, options.map((o) => o.label))} />Options
interface ComboboxOption<V = string> {
value: V; // string or number, unique
label: string; // shown in the list, the control and tags
description?: string; // second line under the label
group?: string; // heading; groups appear in first-seen order
disabled?: boolean; // shown, not selectable, skipped by the keyboard
icon?: ReactNode; // rendered before the label
keywords?: string[]; // extra search terms, e.g. synonyms or codes
}| Field | Type | Description |
|---|---|---|
value | string | number | Identifies the option. Values are what onChange reports and what forms post. |
label | string | Display text. Long labels are shortened with an ellipsis; the value is never cut. |
description | string | Second line. Matches the search but is not highlighted. Makes every row taller. |
group | string | Adds a heading above the first option of that group. |
disabled | boolean | Cannot be chosen, and the keyboard skips it. |
icon | ReactNode | Small icon before the label; also shown in the Select control. |
keywords | string[] | Extra searchable terms, e.g. keywords: ["bharat"] finds "India". |
Props Table
Shared
| Prop | Type | Default | Description |
|---|---|---|---|
options | ComboboxOption<V>[] | required | Options to show. In server mode, the current results. |
mode | "client" | "server" | "client" | client filters options locally. server shows them as given. |
loading | boolean | false | Shows a spinner. Existing options stay visible. |
onSearchChange | (search: string) => void | — | Search text. Fires when the list opens, then debounced in server mode. |
searchDebounce | number | 250 | Debounce in milliseconds (server mode). |
searchable | boolean | true | Show the search box. When false, typing jumps to a matching option. |
hasMore | boolean | — | More results exist: shows "Load more" and requests more while scrolling. |
onLoadMore | () => void | — | Asked for the next page. |
filterFn | (option, search) => boolean | — | Replaces the built-in matching (client mode). |
renderOption | (option, { selected, active }) => ReactNode | — | Custom option content. |
allowCreate | boolean | false | Offers "Create …" when the search matches no option. |
onCreate | (label: string) => void | — | Called with the typed text. Add the option to options yourself. |
label | ReactNode | — | Field label above the control. |
placeholder | string | "Select…" | Shown when nothing is selected. |
description | ReactNode | — | Hint below the control. |
error | ReactNode | — | Error message below the control; also marks the control invalid. |
required | boolean | false | Marks the label and sets aria-required. |
disabled | boolean | false | Blocks opening, choosing and clearing. |
clearable | boolean | true | Show the clear button when something is selected. |
size | "sm" | "md" | "lg" | "md" | Control height and font size (30 / 36 / 44 px). |
name | string | — | Posts hidden inputs so FormData picks the values up. |
id | string | generated | Id of the control; also the base for list and option ids. |
maxHeight | number | 280 | Maximum height of the option list in pixels. |
virtualize | boolean | number | true | Virtualizes above 80 options. Pass a number to change the threshold, or false to disable. |
emptyMessage | ReactNode | "No options found" | Shown when nothing matches. |
onOpenChange | (open: boolean) => void | — | Fires when the list opens or closes. |
className | string | — | Class for the root element. |
classNames | Partial<Record<ComboboxSlot, string>> | — | Classes per slot: root, label, control, value, tag, popover, search, list, option, footer. |
style | CSSProperties | — | Inline style for the root element (e.g. CSS variables). |
localeText | Partial<ComboboxLocaleText> | English | Overrides UI text. See Locale Text. |
ref | Ref<ComboboxHandle<V>> | — | Imperative API. See Imperative API. |
Select
| Prop | Type | Default | Description |
|---|---|---|---|
value | V | null | — | Selected value (controlled). |
defaultValue | V | null | null | Starting value (uncontrolled). |
onChange | (value: V | null, option: ComboboxOption<V> | null) => void | — | Fires on choose and on clear (null). |
closeOnSelect | boolean | true | Close the list after choosing. |
MultiSelect
| Prop | Type | Default | Description |
|---|---|---|---|
value | V[] | — | Selected values (controlled). |
defaultValue | V[] | [] | Starting values (uncontrolled). |
onChange | (values: V[], options: ComboboxOption<V>[]) => void | — | Fires on every change. |
max | number | — | Maximum selections. Extra choices are ignored and the footer says so. |
maxVisibleTags | number | 3 | Tags shown before the rest collapse into "+N more". |
showSelectAll | boolean | true | "Select all" / "Clear all" under the list. |
closeOnSelect | boolean | false | Close the list after each choice. |
The value type comes from options and value only, so passing a state setter straight to onChange works:
const [country, setCountry] = useState<string | null>(null);
<Select options={countries} value={country} onChange={setCountry} />; // V is stringFor number values, type it through the options:
const users: ComboboxOption<number>[] = [{ value: 7, label: "Ada Lovelace" }];
<MultiSelect<number> options={users} value={ids} onChange={setIds} />;Search
- The search box filters as you type. Every word must match, so
south kfinds "South Korea". - Matching runs against the label (and highlights it), the description and keywords.
- Disabled options still appear but cannot be chosen.
- With
searchable={false}there is no search box; typing letters jumps to the first option starting with them, like a native select.
Custom matching:
<Select
options={products}
filterFn={(option, search) => option.value.toString().startsWith(search)}
/>Server Options
In server mode the component does no filtering. It reports the search text and shows whatever options you pass.
"use client";
import { useEffect, useState } from "react";
import { MultiSelect, type ComboboxOption } from "@/components/combobox";
const NO_OPTIONS: ComboboxOption<number>[] = [];
export function PeoplePicker({ value, onChange }: Props) {
const [query, setQuery] = useState({ search: "", page: 0 });
const [result, setResult] = useState<{
query: typeof query;
options: ComboboxOption<number>[];
hasMore: boolean;
} | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(
`/api/people?search=${encodeURIComponent(query.search)}&page=${query.page}`,
{
signal: controller.signal,
},
)
.then((res) => res.json())
.then((body) =>
setResult({ query, options: body.options, hasMore: body.hasMore }),
)
.catch(() => {}); // aborted
return () => controller.abort(); // cancels the previous request
}, [query]);
return (
<MultiSelect<number>
mode="server"
options={result?.options ?? NO_OPTIONS}
hasMore={result?.hasMore}
loading={result?.query !== query}
onSearchChange={(search) => setQuery({ search, page: 0 })}
onLoadMore={() => setQuery((prev) => ({ ...prev, page: prev.page + 1 }))}
value={value}
onChange={onChange}
placeholder="Search people"
/>
);
}With TanStack Query:
const { data, isFetching } = useQuery({
queryKey: ["people", query],
queryFn: ({ signal }) => fetchPeople(query, signal),
placeholderData: keepPreviousData,
});
<MultiSelect mode="server" options={data?.options ?? NO_OPTIONS} loading={isFetching} ... />;How it behaves
onSearchChangefires once when the list opens (with the current text, usually empty), so you can load a first page, then debounced bysearchDebounceas the user types.- The API should return options for the search, plus whether more exist.
- Paging: when the list is scrolled near the end — or when new options arrive and it is already at the end —
onLoadMoreis called. A "Load more" button is shown as well. - Labels of chosen options are remembered, so tags stay readable after the results change. If you set
valuefrom outside before the matching options have loaded, the raw value is shown until they arrive.
Creating Options
const [options, setOptions] = useState(initialTags);
const [values, setValues] = useState<string[]>([]);
<MultiSelect
label="Tags"
options={options}
value={values}
onChange={setValues}
allowCreate
onCreate={(label) => {
const option = { value: label.toLowerCase(), label };
setOptions((prev) => [...prev, option]);
setValues((prev) => [...prev, option.value]);
}}
/>;"Create …" appears when the typed text matches no option's label. Pressing Enter with no option highlighted creates it. The component does not add the option itself: do that in onCreate.
Forms
With a name, values post as hidden inputs — one per value — so FormData and server actions pick them up.
<form
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
console.log(data.get("country"), data.getAll("departments"));
}}
>
<Select
name="country"
label="Country"
required
options={countries}
error={error}
/>
<MultiSelect name="departments" label="Departments" options={departments} />
<button type="submit">Save</button>
</form>error shows a message below the control, colors the border and sets aria-invalid. Validation is yours; the component only displays the state.
Imperative API (ref)
const picker = useRef<ComboboxHandle<string>>(null);
<Select ref={picker} options={countries} />;
picker.current?.open();| Method | Signature | Description |
|---|---|---|
open | () => void | Open the list. |
close | () => void | Close the list. |
toggle | () => void | Open or close. |
focus | () => void | Focus the control without opening. |
clear | () => void | Remove the selection. |
getValue | () => V[] | Selected values (one entry for Select). |
getSelectedOptions | () => ComboboxOption<V>[] | Selected options, including remembered labels. |
Keyboard
| Keys | Action |
|---|---|
| Enter, Space, ↓, ↑ | Open the list. |
| typing (closed) | Opens and starts searching, or jumps to an option when searchable={false}. |
| ↓ / ↑ | Move between options; disabled options are skipped and it wraps around. |
| Home / End | First / last option. |
| Page Down / Page Up | Move ten options. |
| Enter | Choose the highlighted option, or create when "Create …" is shown. |
| Escape | Close the list and return focus to the control. |
| Tab | Close the list and move to the next field. |
| Backspace | MultiSelect: remove the last tag, when the search box is empty. |
Accessibility: the control is a role="combobox" that owns a role="listbox"; the search box keeps focus and the highlighted option is reported with aria-activedescendant. Options carry aria-selected, disabled ones aria-disabled. Group headings are visual only, so include anything essential in the option label.
Performance
- Above 80 options the list is virtualized: only visible rows are in the page. A 10,000-option list renders about a dozen rows.
- Rows have a fixed height per
size(30 / 34 / 40 px), and 16 px taller when any option has adescription. - Filtering is plain string matching over the options array; 10,000 options filter without noticeable delay. For much larger sets, use server mode.
Styling and Theming
All rules are in the CSS components layer, so utility classes passed through className / classNames override them.
<MultiSelect
className="max-w-sm"
classNames={{ control: "shadow-sm", tag: "uppercase tracking-wide" }}
options={options}
/>CSS variables
Override them on .cb-root, on any ancestor, or through style. Each variable falls back to the shared --gbs-* of the same name, then to the DataGrid's --dg-* when that stylesheet is loaded, and finally to the built-in palette.
| Variable | Used for |
|---|---|
--cb-font-size | Base font size. |
--cb-height | Control height (set by size). |
--cb-px | Horizontal padding inside the control. |
--cb-bg, --cb-fg | Background and text color. |
--cb-muted | Placeholder, descriptions and icons. |
--cb-border | Borders. |
--cb-hover | Hover background of options and buttons. |
--cb-input-bg | Control and search background. |
--cb-accent, --cb-accent-fg | Check marks, highlight and focus accents. |
--cb-accent-soft, --cb-accent-strong | Tag background and text. |
--cb-focus | Focus ring. |
--cb-danger | Error state. |
--cb-shadow | List shadow. |
--cb-radius | Corner radius. |
.cb-root {
--cb-accent: #7c3aed;
--cb-radius: 12px;
}Dark mode
Colors follow the page's color-scheme. To force a scheme, put class="dark" or data-theme="dark" (or "light") on an ancestor such as <html>.
Data attributes
| Element | Attributes |
|---|---|
Root (.cb-root) | data-size, data-state (open / closed), data-invalid |
Control (.cb-control) | data-disabled, data-invalid |
Option (.cb-item) | data-active, data-selected, data-disabled |
Locale Text
<MultiSelect
localeText={{
searchPlaceholder: "Suchen…",
noResults: "Keine Treffer",
selectedCount: (count) => `${count} ausgewählt`,
}}
/>| Key | Default |
|---|---|
searchPlaceholder | "Search…" |
noResults | "No options found" |
loading | "Loading…" |
loadMore | "Load more" |
clear | "Clear" |
clearAll | "Clear all" |
selectAll | "Select all" |
createOption | (label) => "Create “{label}”" |
selectedCount | (count) => "{count} selected" |
moreCount | (count) => "+{count} more" |
removeOption | (label) => "Remove {label}" |
maxReached | (max) => "Maximum {max} selected" |
Headless Use
useCombobox() holds the whole engine — filtering, the highlighted option, keyboard handling, selection and search requests — and is exported for building a different UI on the same behavior:
const controlRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const combobox = useCombobox({
options,
multiple: false,
defaultValue: [],
onValuesChange: (values, options) => …,
text: defaultComboboxText,
id: "my-picker",
controlRef,
searchRef,
listRef,
});It returns open, search, entries, items, activeIndex, selectedOptions, isSelected, selectOption, removeValue, clear, selectAllVisible, createLabel, commitCreate, onKeyDown, onControlClick, onControlPointerDown, onPopoverClose and handle.
The framework-free helpers are exported from @/components/combobox/core and can also run on a server:
| Export | Description |
|---|---|
filterOptions(options, search, filterFn?) | Matching options with highlight ranges. |
matchRanges(text, terms) | Ranges matching every term, or null. |
splitTerms(search) | Splits search text into lowercase terms. |
buildListItems(entries) | Adds group headings and numbers the options. |
nextEnabledIndex / firstEnabledIndex / lastEnabledIndex | Keyboard movement over selectable options. |
findByPrefix(entries, prefix, from) | Type-ahead lookup. |
toggleValue(values, value, max?) | Adds or removes a value, respecting max. |
measureItems / getVisibleRange / scrollToItem | List virtualization math. |
Migrating from the Previous Select / MultiSelect
| Previous | New |
|---|---|
items={[{ value, label }]} | options={[{ value, label }]} (plus group, description, disabled, icon, keywords) |
items="https://api/items" | Fetch in your component and pass options. The component no longer fetches. |
lazy | mode="server" with onSearchChange, loading and hasMore / onLoadMore |
showSearch | searchable |
onFiltering | onSearchChange |
onSelect (Select) | onChange(value, option) |
onSelect (MultiSelect) | onChange(values, options) |
selectedItem | value / defaultValue |
selectedItems | value / defaultValue (array) |
truncate | maxVisibleTags (a large number shows all tags) |
error, disabled, placeholder, name, className | Same names |
| Labels cut at 20 characters | Full labels, shortened visually with an ellipsis |
ref.clearSelected() | ref.clear() |
ref.togglePopover() | ref.toggle() |
ref.selected | ref.getValue() |
ref.selectedDisplay | ref.getSelectedOptions() |
ref.getSelectItems(url) | Fetch in your app and pass options |
ref.workingDataSource | options (you own the list) |
PortalDropdown | Built in: the list uses the native Popover API, so no portal is needed |
Notes
- Option rows have a fixed height per
size, because the list is virtualized. - There is no "load a single option by value": pass options that include the selected values, or let the user pick them at least once.
- Tree or multi-level options are not supported.