Components · Forms

Combobox

A typeable input + filtered listbox. Pick one or many; type to filter; optionally add values that aren't on the list. Reach for it on table filters and any form field whose option set is too long for a Select.

Documentedby Derek Fidler
Status

Filter the transactions table by settlement state.

Overview

The Combobox combines a free-form text input (the user types to filter) with a dropdown listbox of options. Single- or multi-select, optional “manual entry” for values the user wants to add on the fly. The mental model is two independent streams: the input carries the live query; the dropdown carries the filtered options; selectionis its own thing. Typing doesn't change selection, removing a chip doesn't change the query.

Combobox vs Select

Reach for Selectwhen the option set is short (≲ 12 items), stable, and there's no real value in typing to narrow it. Reach for Comboboxwhen the user wouldn't want to scroll the whole list — long catalogs of locations, tags, statuses, devices — or when they need to pick more than one. Table filters are almost always Combobox with multiple.

Variants

Single, multi, multi + manual entry, plus the canonical error treatment. Switching between them is a prop.

Location

Pick one — the table filters to a single location.

single

One value at a time. Selecting closes the menu and writes the option's label into the input.

Locations
  • Amsterdam Centraal
  • Amsterdam Noord

Apply this setting to one or more locations.

multi

Many values. Menu stays open after each pick; the query clears so the user can keep narrowing. Selected values render as chips below.

Tags
  • VIP
  • Delivery

Type to filter, or add a new tag.

multi + manual entry

When `allowCreate` is on, the user can add a value that isn't on the list — useful for free-form tag pickers.

Status

Pick at least one status before applying the filter.

error

Truthy `error` swaps the surface to the canonical danger fill and renders the message below the field.

Table filter

The combobox earns its keep on the toolbar above a data table — two or three multi-select chips that drive a server query. Below: a real live demo with a status + location filter.

Filters
Status
  • Settled
  • Pending
Location

Showing transactions where status is 2 selected and location is any.

Applied filters drive a real query in production — this preview just renders the readable summary so the round-trip between selection, chip, and table-state is legible.

States

Default, hover, active (open), focus, disabled, error. Hover, active, and focus are CSS-driven on the live component — interact with the showcase above to see them. The two below hold disabled and error still for review.

Status

Disabled — read-only state for forms locked by permissions.

Status

Pick at least one status before applying the filter.

Behavior

The same shell carries the input and the surface; the listbox opens beneath it on focus or click.

TopicRule
Open triggerFocus or click anywhere on the shell. The chevron icon also toggles open/close — but the user rarely needs it because tab + arrow is faster.
Close triggerClick outside, press Escape, pick an option (single mode), or Tab away.
Selection vs queryThese are independent streams. Typing never changes selection; selecting clears the query but doesn't change focus. The shell stays focused throughout.
FilteringThe default filter is case-insensitive substring against `label` and `description`. Override with `filter` for fuzzy matches or server-side indexing.
Manual entryWhen `allowCreate` is on and the query doesn't match any existing option, an 'Add "X"' row appears at the bottom. Activating it fires `onCreate` AND adds the new value to the selection so the user sees their work acknowledged.
Form serializationSet `name` and the component renders hidden inputs (one per selected value in multi mode). The DOM submits the same way a checkbox group would.

Accessibility

The component implements the WAI-ARIA combobox pattern: the input reports role="combobox", the listbox role="listbox", and each option role="option" with aria-selected. Focus stays in the input throughout; arrow keys move aria-activedescendant across rows so screen readers track the active option without stealing keyboard focus.

KeyAction
↓ / ↑Move the active option up / down. Opens the dropdown if it's closed.
EnterToggle the active option. Multi keeps the menu open; single closes.
EscapeClose the dropdown. If the query is non-empty, clear it first.
BackspaceOn an empty query: remove the last selected chip (multi) or clear the value (single).
TabIn multi mode, commits the active option then advances focus. In single mode, just advances.
Home / EndJump to the first / last visible option.
Type lettersFilter the option list. The default filter matches label + description, case-insensitive.

Multi-select keeps the menu open

When multiple is true, selecting a row toggles it without closing the dropdown so the user can pick a few in a row. Single-select closes on commit. Either mode clears the query after each pick so the next search starts from a clean slate.

Code

Single-select

tsx

import { useState } from "react";
import { Combobox, type ComboboxOption } from "@flatpay-dk/ui";

const STATUSES: ComboboxOption[] = [
  { value: "settled", label: "Settled", description: "Funds in the merchant's account" },
  { value: "pending", label: "Pending", description: "Awaiting payout window" },
  { value: "failed", label: "Failed", description: "Returned by the acquirer" },
];

export function StatusFilter() {
  const [status, setStatus] = useState<string | null>(null);
  return (
    <Combobox
      label="Status"
      description="Filter the transactions table."
      placeholder="All statuses"
      options={STATUSES}
      value={status}
      onValueChange={setStatus}
    />
  );
}

Multi-select (table filter)

tsx

<Combobox
  label="Locations"
  description="Apply this setting to one or more locations."
  placeholder="Select locations…"
  options={LOCATIONS}
  multiple
  value={selected}
  onValueChange={setSelected}
/>;

Multi + manual entry (tag picker)

tsx

<Combobox
  label="Tags"
  description="Type to filter, or add a new tag."
  placeholder="Add tags…"
  options={tagOptions}
  multiple
  value={tags}
  onValueChange={setTags}
  allowCreate
  onCreate={(label) => {
    const value = label.toLowerCase().replace(/\s+/g, "-");
    setTagOptions((prev) =>
      prev.some((o) => o.value === value) ? prev : [...prev, { value, label }],
    );
  }}
/>;

Custom filter (e.g. fuzzy match against a server-side index)

tsx

<Combobox
  label="Devices"
  options={devices}
  multiple
  value={selected}
  onValueChange={setSelected}
  filter={(opts, q) => fuzzy.search(opts, q, ["label", "serialNumber"])}
/>;

Best practices

Status
  • Settled
  • Pending

Do

Use multi for table filters. The chips show the user what's applied; clearing each is one click.

Frequency

Don't

Don't use Combobox when Select is enough. A 4-item static list doesn't need a query input.

Tags

Do

Use allowCreate when the option set is open-ended — tags, custom labels, free-form categories.

Status

Don't

Don't enable allowCreate on closed sets. Letting the user invent a status they then can't filter on is a foot-gun.

Props

PropTypeDefaultDescription
options*ComboboxOption[]The full option list. Filtered live by the user's query.
labelReactNodeFloating label rendered above the value.
descriptionReactNodeHelper line below the field. Hidden when an error is set.
errorReactNodeTruthy enters the error state. Pass a string for the message.
placeholderstringShown when there's no value and no query.
multiplebooleanfalseSwitch the component from single-select (returns string | null) to multi-select (returns string[]). Multi shows chips below and keeps the dropdown open after each pick.
valuestring | null / string[]Controlled selection. Pair with `onValueChange`. Use `defaultValue` for uncontrolled.
defaultValuestring | null / string[]Initial selection when uncontrolled.
onValueChange(next) => voidFires when the selection changes. Receives `string | null` (single) or `string[]` (multi).
allowCreatebooleanfalseShow an 'Add "X"' row when the query doesn't match any existing option. Selecting it fires `onCreate` and adds the value to the selection.
onCreate(label: string) => voidFires when the user activates the 'Add' row.
filter(options, query) => optionsCustom filter. Defaults to case-insensitive substring match on label and description.
emptyStateReactNode'No results.'Shown when the filter yields nothing and `allowCreate` is off.
disabledbooleanfalseInert surface. Selection still serializes to hidden inputs for forms.
requiredbooleanfalseNative form validation; renders the required asterisk in the label.
namestringWhen set, hidden inputs serialize the selection (one per value in multi).
idstringElement id for the input. Generated if omitted.
classNamestringForwarded to the wrapper.

ComboboxOption

PropTypeDefaultDescription
value*stringStable identifier. Used for selection and as the React key.
label*stringVisible label. Matched against the user's query by the default filter.
descriptionstringOptional secondary line beneath the label.
disabledbooleanInert option. Filters still match it; it just can't be selected.