Skip to content
Beta · ExperimentalReact 19No peer dependencies

Combobox

Select and MultiSelect — searchable comboboxes for one or many values, with options in the browser or from an API.

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

Installation

bash
npx gbs-add-block -a Combobox -beta

The 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/react 19
  • 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:

css
@import "../components/combobox/styles.css";

or in your root layout / entry file:

ts
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.

Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

Select

Live preview

MultiSelect

Live preview

Quick Start

tsx
"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:

tsx
<Select onChange={(value, option) => console.log(value, option?.label)} />
<MultiSelect onChange={(values, options) => console.log(values, options.map((o) => o.label))} />
Define options outside the component or wrap them in useMemo. A new options array on every render makes the list re-filter each time. With React Compiler enabled this is handled for you.

Options

ts
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
}
FieldTypeDescription
valuestring | numberIdentifies the option. Values are what onChange reports and what forms post.
labelstringDisplay text. Long labels are shortened with an ellipsis; the value is never cut.
descriptionstringSecond line. Matches the search but is not highlighted. Makes every row taller.
groupstringAdds a heading above the first option of that group.
disabledbooleanCannot be chosen, and the keyboard skips it.
iconReactNodeSmall icon before the label; also shown in the Select control.
keywordsstring[]Extra searchable terms, e.g. keywords: ["bharat"] finds "India".

Props Table

Shared

PropTypeDefaultDescription
optionsComboboxOption<V>[]requiredOptions to show. In server mode, the current results.
mode"client" | "server""client"client filters options locally. server shows them as given.
loadingbooleanfalseShows a spinner. Existing options stay visible.
onSearchChange(search: string) => voidSearch text. Fires when the list opens, then debounced in server mode.
searchDebouncenumber250Debounce in milliseconds (server mode).
searchablebooleantrueShow the search box. When false, typing jumps to a matching option.
hasMorebooleanMore results exist: shows "Load more" and requests more while scrolling.
onLoadMore() => voidAsked for the next page.
filterFn(option, search) => booleanReplaces the built-in matching (client mode).
renderOption(option, { selected, active }) => ReactNodeCustom option content.
allowCreatebooleanfalseOffers "Create …" when the search matches no option.
onCreate(label: string) => voidCalled with the typed text. Add the option to options yourself.
labelReactNodeField label above the control.
placeholderstring"Select…"Shown when nothing is selected.
descriptionReactNodeHint below the control.
errorReactNodeError message below the control; also marks the control invalid.
requiredbooleanfalseMarks the label and sets aria-required.
disabledbooleanfalseBlocks opening, choosing and clearing.
clearablebooleantrueShow the clear button when something is selected.
size"sm" | "md" | "lg""md"Control height and font size (30 / 36 / 44 px).
namestringPosts hidden inputs so FormData picks the values up.
idstringgeneratedId of the control; also the base for list and option ids.
maxHeightnumber280Maximum height of the option list in pixels.
virtualizeboolean | numbertrueVirtualizes above 80 options. Pass a number to change the threshold, or false to disable.
emptyMessageReactNode"No options found"Shown when nothing matches.
onOpenChange(open: boolean) => voidFires when the list opens or closes.
classNamestringClass for the root element.
classNamesPartial<Record<ComboboxSlot, string>>Classes per slot: root, label, control, value, tag, popover, search, list, option, footer.
styleCSSPropertiesInline style for the root element (e.g. CSS variables).
localeTextPartial<ComboboxLocaleText>EnglishOverrides UI text. See Locale Text.
refRef<ComboboxHandle<V>>Imperative API. See Imperative API.

Select

PropTypeDefaultDescription
valueV | nullSelected value (controlled).
defaultValueV | nullnullStarting value (uncontrolled).
onChange(value: V | null, option: ComboboxOption<V> | null) => voidFires on choose and on clear (null).
closeOnSelectbooleantrueClose the list after choosing.

MultiSelect

PropTypeDefaultDescription
valueV[]Selected values (controlled).
defaultValueV[][]Starting values (uncontrolled).
onChange(values: V[], options: ComboboxOption<V>[]) => voidFires on every change.
maxnumberMaximum selections. Extra choices are ignored and the footer says so.
maxVisibleTagsnumber3Tags shown before the rest collapse into "+N more".
showSelectAllbooleantrue"Select all" / "Clear all" under the list.
closeOnSelectbooleanfalseClose the list after each choice.

The value type comes from options and value only, so passing a state setter straight to onChange works:

tsx
const [country, setCountry] = useState<string | null>(null);
<Select options={countries} value={country} onChange={setCountry} />; // V is string

For number values, type it through the options:

tsx
const users: ComboboxOption<number>[] = [{ value: 7, label: "Ada Lovelace" }];
<MultiSelect<number> options={users} value={ids} onChange={setIds} />;
  • The search box filters as you type. Every word must match, so south k finds "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:

tsx
<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.

tsx
"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:

tsx
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

  • onSearchChange fires once when the list opens (with the current text, usually empty), so you can load a first page, then debounced by searchDebounce as 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 — onLoadMore is 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 value from outside before the matching options have loaded, the raw value is shown until they arrive.

Creating Options

tsx
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.

tsx
<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)

tsx
const picker = useRef<ComboboxHandle<string>>(null);
 
<Select ref={picker} options={countries} />;
 
picker.current?.open();
MethodSignatureDescription
open() => voidOpen the list.
close() => voidClose the list.
toggle() => voidOpen or close.
focus() => voidFocus the control without opening.
clear() => voidRemove the selection.
getValue() => V[]Selected values (one entry for Select).
getSelectedOptions() => ComboboxOption<V>[]Selected options, including remembered labels.

Keyboard

KeysAction
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 / EndFirst / last option.
Page Down / Page UpMove ten options.
EnterChoose the highlighted option, or create when "Create …" is shown.
EscapeClose the list and return focus to the control.
TabClose the list and move to the next field.
BackspaceMultiSelect: 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 a description.
  • 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.

tsx
<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.

VariableUsed for
--cb-font-sizeBase font size.
--cb-heightControl height (set by size).
--cb-pxHorizontal padding inside the control.
--cb-bg, --cb-fgBackground and text color.
--cb-mutedPlaceholder, descriptions and icons.
--cb-borderBorders.
--cb-hoverHover background of options and buttons.
--cb-input-bgControl and search background.
--cb-accent, --cb-accent-fgCheck marks, highlight and focus accents.
--cb-accent-soft, --cb-accent-strongTag background and text.
--cb-focusFocus ring.
--cb-dangerError state.
--cb-shadowList shadow.
--cb-radiusCorner radius.
css
.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

ElementAttributes
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

tsx
<MultiSelect
  localeText={{
    searchPlaceholder: "Suchen…",
    noResults: "Keine Treffer",
    selectedCount: (count) => `${count} ausgewählt`,
  }}
/>
KeyDefault
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:

tsx
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:

ExportDescription
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 / lastEnabledIndexKeyboard movement over selectable options.
findByPrefix(entries, prefix, from)Type-ahead lookup.
toggleValue(values, value, max?)Adds or removes a value, respecting max.
measureItems / getVisibleRange / scrollToItemList virtualization math.

Migrating from the Previous Select / MultiSelect

PreviousNew
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.
lazymode="server" with onSearchChange, loading and hasMore / onLoadMore
showSearchsearchable
onFilteringonSearchChange
onSelect (Select)onChange(value, option)
onSelect (MultiSelect)onChange(values, options)
selectedItemvalue / defaultValue
selectedItemsvalue / defaultValue (array)
truncatemaxVisibleTags (a large number shows all tags)
error, disabled, placeholder, name, classNameSame names
Labels cut at 20 charactersFull labels, shortened visually with an ellipsis
ref.clearSelected()ref.clear()
ref.togglePopover()ref.toggle()
ref.selectedref.getValue()
ref.selectedDisplayref.getSelectedOptions()
ref.getSelectItems(url)Fetch in your app and pass options
ref.workingDataSourceoptions (you own the list)
PortalDropdownBuilt 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.