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.
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.
- 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.
- 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.
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.
- Settled
- Pending
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.
Disabled — read-only state for forms locked by permissions.
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.
| Topic | Rule |
|---|---|
| Open trigger | Focus 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 trigger | Click outside, press Escape, pick an option (single mode), or Tab away. |
| Selection vs query | These are independent streams. Typing never changes selection; selecting clears the query but doesn't change focus. The shell stays focused throughout. |
| Filtering | The default filter is case-insensitive substring against `label` and `description`. Override with `filter` for fuzzy matches or server-side indexing. |
| Manual entry | When `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 serialization | Set `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.
| Key | Action |
|---|---|
| ↓ / ↑ | Move the active option up / down. Opens the dropdown if it's closed. |
| Enter | Toggle the active option. Multi keeps the menu open; single closes. |
| Escape | Close the dropdown. If the query is non-empty, clear it first. |
| Backspace | On an empty query: remove the last selected chip (multi) or clear the value (single). |
| Tab | In multi mode, commits the active option then advances focus. In single mode, just advances. |
| Home / End | Jump to the first / last visible option. |
| Type letters | Filter 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
- Settled
- Pending
Do
Use multi for table filters. The chips show the user what's applied; clearing each is one click.
Don't
Don't use Combobox when Select is enough. A 4-item static list doesn't need a query input.
Do
Use allowCreate when the option set is open-ended — tags, custom labels, free-form categories.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | ComboboxOption[] | — | The full option list. Filtered live by the user's query. |
| label | ReactNode | — | Floating label rendered above the value. |
| description | ReactNode | — | Helper line below the field. Hidden when an error is set. |
| error | ReactNode | — | Truthy enters the error state. Pass a string for the message. |
| placeholder | string | — | Shown when there's no value and no query. |
| multiple | boolean | false | Switch 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. |
| value | string | null / string[] | — | Controlled selection. Pair with `onValueChange`. Use `defaultValue` for uncontrolled. |
| defaultValue | string | null / string[] | — | Initial selection when uncontrolled. |
| onValueChange | (next) => void | — | Fires when the selection changes. Receives `string | null` (single) or `string[]` (multi). |
| allowCreate | boolean | false | Show 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) => void | — | Fires when the user activates the 'Add' row. |
| filter | (options, query) => options | — | Custom filter. Defaults to case-insensitive substring match on label and description. |
| emptyState | ReactNode | 'No results.' | Shown when the filter yields nothing and `allowCreate` is off. |
| disabled | boolean | false | Inert surface. Selection still serializes to hidden inputs for forms. |
| required | boolean | false | Native form validation; renders the required asterisk in the label. |
| name | string | — | When set, hidden inputs serialize the selection (one per value in multi). |
| id | string | — | Element id for the input. Generated if omitted. |
| className | string | — | Forwarded to the wrapper. |
ComboboxOption
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Stable identifier. Used for selection and as the React key. |
| label* | string | — | Visible label. Matched against the user's query by the default filter. |
| description | string | — | Optional secondary line beneath the label. |
| disabled | boolean | — | Inert option. Filters still match it; it just can't be selected. |