Combobox
A single field combining a text input with a filtered list of options.
Installation
- Install the package.
npm install @hashintel/ds-components- Import the component.
import { useState } from "react";
import { Combobox } from "@hashintel/ds-components";
const entityTypeItems = [
{ value: "person", text: "Person" },
{ value: "company", text: "Company" },
];
export const EntityTypePicker = () => {
const [entityType, setEntityType] = useState<string | null>(null);
return (
<Combobox
items={entityTypeItems}
value={entityType}
onChange={setEntityType}
placeholder="Search entity types…"
/>
);
};When to use
- Do: Use Combobox to pick from a long list — roughly 10+ options — where typing to filter beats scrolling.
- Do: Use
multipleto select several values from one list, rendered as removable chips inside the field. - Do: Use
allowNewValuewhen typed text is as valid as the predefined options: tags, labels, units.
When not to use
- Don't: Don't use Combobox for short lists users should scan whole — consider Select for under ~10 options, or RadioGroup/CheckboxGroup for 5 or fewer.
- Don't: Don't use the dropdown to run actions or navigate — use Menu.
- Don't: Don't use Combobox for free text without meaningful suggestions — use TextInput.
Anatomy
Combobox renders its parts — the input, the selected-value chips, and the
dropdown list — from props rather than JSX children. Options are objects with
a value (what onChange receives) and a text (what the user sees and
filters against), optionally wrapped in labelled groups:
<Combobox
items={[
// an option
{ value: "person", text: "Person" },
// a labelled group of options
{
id: "system-types",
label: "System types",
items: [
{ value: "actor", text: "Actor" },
{ value: "organization", text: "Organization" },
],
},
]}
value={value}
onChange={setValue}
placeholder="Search entity types…"
/>With multiple, options also accept suffix, showOnlyButton, and
selectedTone, and value/onChange carry arrays.
Examples
Basic
Pass items, the committed value (or null for none), and onChange.
Typing filters the dropdown; a selection commits and fills the input, and
abandoning typed text reverts it.
Disabled and invalid
disabled blocks all interaction; invalid marks the field for error
styling. Options can also be disabled individually via each item's disabled flag.
Sizes
md is the default. Use xxs–sm in dense chrome such as toolbars and
table rows, and lg for spacious forms. Chips inside a multiple combobox
scale with the field.
Subtle variant
subtle drops the border so the field reads as inline text until hovered or
focused.
Grouped options
Wrap related options in { id, label, items } entries. Filtering keeps a
group's label while any of its options match and removes the group entirely
when none do.
Custom rendering
renderItem draws each dropdown option (and, with multiple, each chip);
renderSelectedItem overrides how the committed value shows in the input. Each
option's text still drives filtering, so keep the rendered output's words
aligned with it.
import { useState } from "react";
import { Combobox } from "@hashintel/ds-components";
const statusItems = [
{ value: "draft", text: "Draft" },
{ value: "in-review", text: "In review" },
{ value: "published", text: "Published" },
];
const statusColors: Record<string, string> = {
draft: "#9CA3AF",
"in-review": "#F59E0B",
published: "#10B981",
};
const renderStatus = (value: string) => (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span
aria-hidden
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: statusColors[value],
}}
/>
{statusItems.find((item) => item.value === value)?.text ?? value}
</span>
);
export const StatusPicker = () => {
const [status, setStatus] = useState<string | null>("in-review");
return (
<Combobox
items={statusItems}
value={status}
onChange={setStatus}
renderItem={renderStatus}
renderSelectedItem={renderStatus}
/>
);
};With multiple, renderSelectedAll replaces the whole chip row instead
(reusing statusItems from above):
export const StatusMultiPicker = () => {
const [statuses, setStatuses] = useState<string[]>(["draft", "in-review"]);
return (
<Combobox
multiple
items={statusItems}
value={statuses}
onChange={setStatuses}
renderSelectedAll={(values) => <>{values.length} statuses selected</>}
/>
);
};Filtering
The default filter matches options whose text contains the typed text,
case-insensitively. Switch to startsWith, supply your own predicate, or
pass "none" to show every option regardless of input (when filtering
happens elsewhere — see Async search).
No-match and empty states
noMatchMessage replaces the default "No matching options" when typed text
filters everything out; emptyState replaces "No options available" when
there are no options at all. Say what to do next, not just that nothing
matched.
import { useState } from "react";
import { Combobox } from "@hashintel/ds-components";
const teammateItems = [
{ value: "amara", text: "Amara Okafor" },
{ value: "dev", text: "Dev Patel" },
{ value: "ines", text: "Inés Fernández" },
];
export const AssigneePicker = () => {
const [assignee, setAssignee] = useState<string | null>(null);
return (
<Combobox
items={teammateItems}
value={assignee}
onChange={setAssignee}
noMatchMessage={(input) => <>No teammates match “{input}”</>}
emptyState="No teammates in this workspace yet"
placeholder="Assign a teammate…"
/>
);
};Async search
For options fetched as the user types, drive the fetch from onChangeInput,
turn off built-in filtering with filterAlgorithm="none" (the server already
filtered), and show loading while the request runs.
import { useRef, useState } from "react";
import { Combobox } from "@hashintel/ds-components";
import type { ComboboxItem } from "@hashintel/ds-components";
export const EntitySearch = () => {
const [results, setResults] = useState<ComboboxItem[]>([]);
const [loading, setLoading] = useState(false);
const [entityId, setEntityId] = useState<string | null>(null);
const latestRequestRef = useRef(0);
const search = async (query: string) => {
const requestId = ++latestRequestRef.current;
setLoading(true);
try {
const entities = await fetchEntities(query); // your API
if (requestId === latestRequestRef.current) {
setResults(
entities.map((entity) => ({ value: entity.id, text: entity.name })),
);
}
} catch {
if (requestId === latestRequestRef.current) {
setResults([]);
}
} finally {
if (requestId === latestRequestRef.current) {
setLoading(false);
}
}
};
return (
<Combobox
items={results}
value={entityId}
onChange={setEntityId}
onChangeInput={(text) => void search(text)}
filterAlgorithm="none"
loading={loading}
placeholder="Search entities…"
/>
);
};Multiple selection
Set multiple to select several values: value and onChange carry arrays,
selected values render as removable chips inside the field, and the dropdown
stays open while toggling options. Typed text is a transient filter — it
clears on selection and discards on close.
Chip overflow
overflow decides how chips behave when they outgrow the field: scroll
(default) scrolls them horizontally, truncate collapses the rest into a
"+X" badge while unfocused, and summary shows a plain-text list of names,
falling back to "X of Y" once they no longer fit ("none" and "any" when
nothing or everything is selected).
Limiting selection
maxItems caps the selection; once reached, unselected options disable until
a value is deselected. Consider saying the limit in the placeholder or label so users
aren't surprised.
Item suffixes and the Only button
With multiple, an item's suffix aligns extra content to the option's
right edge — counts, shortcuts — and showOnlyButton offers an "Only"
button on hover that narrows the selection to just that item, as in filter
panels.
Allowing new values
Set allowNewValue to let typed text that matches no option commit as a
value of its own, offered as an extra dropdown option (and committed by
Enter); onChange then receives isNew to distinguish typed values from
picked ones. Combined with multiple it makes a free-form tag input: typed
text becomes a chip, and committed new values list as selected leading options
so they can be deselected like any other.
Pass an options object to always show the option or restyle it:
<Combobox
items={unitItems}
allowNewValue={{
alwaysShowOption: true,
renderOption: (input) => <>Use custom unit “{input}”</>,
}}
value={unit}
onChange={(next, isNew) => setUnit(next)}
/>Connected inputs
Set connectToRightInput on the left input and connectToLeftInput on the
right to join a pair into one visual control — here a filter row pairing a
Select operator with a Combobox value. Only the default variant connects —
subtle and readonly inputs render separately.
Behavior
- Typed text is a draft. Closing the dropdown without selecting reverts a single combobox to the committed value and clears a multiple one's filter.
- Enter with nothing highlighted commits typed text that names an enabled
option if it exists: an exact
valuematch or a case-insensitivetextmatch. Disabled options never match. - Escape reverts the draft; in a multiple combobox it also restores the selection from when the dropdown opened.
- Selected values missing from
itemsstill render and list as leading dropdown options so they can be deselected. This keepsallowNewValueselections and stale data removable.
Content
- Write option
textas short, unique noun phrases in sentence case — filtering and typed-text commits match against it, so front-load the words users will type. - Order options by expected frequency of use, or alphabetically when no ordering is obvious.
- Placeholders should name the action — "Search entity types…", "Add teammates…" — and avoid carrying information users must remember, as they disappear on input.
- Make
noMatchMessagename the next step: "No teammates match — invite one from Settings". - Keep group labels to one or two category words: "System types", "Recently used".
Accessibility
Combobox implements the WAI-ARIA combobox pattern.
What Combobox provides
- A single combobox is a real input carrying
nameandrequired; a multiple one mirrors the selection into a hidden multi-select. Validity tracks the selection rather than the typed filter. - Labelled controls for pointer interactions: each chip's remove button is named "Remove [option text]" and the clear button "Clear input".
What you must do
- Label every combobox — wrap it in
FormFieldwith aLabel, or passaria-label. A placeholder is not a label. - Keep
renderItemoutput textual and consistent with the option'stext, which is what filtering matches and the input displays. - Don't convey meaning through a suffix or custom rendering alone — the option's text must stand on its own.
Keyboard
Beyond the standard combobox pattern:
| Key | Action |
|---|---|
| Enter | Selects the highlighted option; with nothing highlighted, commits the first matched option (or the input text with allowNewValue). |
| Escape | Reverts the draft text; a multiple combobox also restores the selection from when the dropdown opened. |
| Backspace (multiple) | Removes the last chip if the input is empty. |
| ArrowLeft / ArrowRight (multiple) | Move between chips. |
A multiple combobox never submits the surrounding form on Enter; a single combobox, Enter submits the form only if there are no changes on the combobox.
API reference
Combobox
| Prop | Type | Description |
|---|---|---|
| value | string | readonly string[] | readonly NoInfer<TValue>[] | null | undefined | |
| onChange | ((value: string, isNew: boolean) => void) | ((value: NoInfer<TValue>) => void) | ((value: NoInfer<TValue> | null) => void) | ((value: NoInfer<TValue> | null) => void) | ((value: string[], isNew: boolean) => void) | ((value: NoInfer<TValue>[]) => void) | Called when the selection is committed — with the single value (null when cleared) or the array of selected values. With allowNewValue, isNew is true when the commit added typed text matching no option. |
| onClick | MouseEventHandler<Element> | |
| size | "xxs" | "xs" | "sm" | "md" | "lg" default: "md" | The size (height) of the element |
| placeholder | string | |
| prefix | PrefixOrSuffix | Element rendered at the start of the input. |
| suffix | PrefixOrSuffix | Element rendered at the end of the input. |
| variant | "subtle" | "default" default: "default" | The input's visual style: subtle removes the border so it reads as inline text. |
| type | HTMLInputTypeAttribute | The HTML type of the underlying input element, such as text or number. |
| loading | boolean | Whether a loading spinner is shown inside the input. |
| disabled | boolean | Set to show the input as disabled |
| tabIndex | number | |
| autoFocus | boolean | "never" | Set to true to make the element focused on mount or 'never' to prevent the item being auto-focused |
| onKeyDown | KeyboardEventHandler<Element> | |
| onFocus | FocusEventHandler<HTMLInputElement> | The onFocus handler |
| onBlur | FocusEventHandler<HTMLInputElement> | The onBlur handler |
| width | "xs" | "sm" | "md" | "lg" | "fullWidth" | "fitContent" default: "fullWidth" | A set of standard width presets, for aligning with other inputs. |
| required | boolean | Marks the input as required: sets the native required attribute, so assistive technology announces it and form validation blocks empty submits. Purely functional — the input looks unchanged; pair it with Form.Field's required for the visible mark. |
| name | string | The name of the input |
| invalid | boolean | Set to show the input as invalid |
| inputMode | "search" | "text" | "none" | "email" | … 4 more | The keyboard that should be used on mobile devices. |
| readonly | boolean | Whether editing is disabled. Unlike disabled, strips the input styles and renders the value as plain text. |
| align | "left" | "right" | "center" | The alignment of the text inside the input. |
| connectToLeftInput | boolean | Whether this input renders as connected to the input on its left. Set the matching prop on both inputs; subtle and readonly inputs are never connected. |
| connectToRightInput | boolean | Whether this input renders as connected to the input on its right. Set the matching prop on both inputs; subtle and readonly inputs are never connected. |
| styledValue | ReactNode | A formatted view of the value, shown while the input is unfocused. |
| showEditIcon | boolean | |
| maxLength | number | |
| minLength | number | |
| pattern | string | |
| spellcheck | boolean | |
| onChangeInput | ((value: string) => void) | Called as the input's text changes, whether typed or set by a selection |
| filterAlgorithm | "none" | "startsWith" | "contains" | ((input: string, text: string, value: string) => boolean) default: "contains" | How options are filtered against the typed text. |
| noMatchMessage | ((input: string) => ReactNode) | Rendered in the dropdown when the typed text matches no option, replacing the default "No matching options". With no options to filter at all, the dropdown shows emptyState instead. |
| emptyState | ReactNode | Rendered in the dropdown when there are no options at all, replacing the default "No options available" |
| multiple | boolean default: false | Whether multiple values can be selected. |
| maxItems | number | The maximum number of values that can be selected. Once reached, unselected items are disabled until a value is deselected. |
| overflow | "truncate" | "scroll" | "summary" default: "scroll" | How the selected values render in the input when no renderSelectedAll is given: a chip row that scrolls horizontally (the default), a chip row that truncates with a "+X" badge, or a plain-text summary of the names (falling back to "X of Y" once they no longer fit). |
| clearInputOnSelect | boolean default: true | Whether a selection change made from the dropdown — toggling an option, or an item's "Only" button — clears the typed text. |
| renderSelectedAll | ((values: TValue[]) => ReactNode) | Custom renderer for the selected values in the input, replacing the default overflow row |
| items | readonly ItemOrGroup<ComboboxItem<TValue>>[] | readonly ItemOrGroup<MultiComboboxItem<TValue>>[] default: [] | |
| renderItem | ((value: TValue) => ReactNode) | ((value: TValue) => ReactNode) | Custom renderer for options in the dropdown; in a multiple combobox, also each selected value's chip content unless renderSelectedItem overrides it. Defaults to the option's text. |
| renderSelectedItem | ((value: TValue) => ReactNode) | ((value: TValue) => ReactNode) | Custom renderer for a selected value in the input — the committed value shown while unfocused in a single combobox, or each value's chip content in a multiple one. Defaults to renderItem, then the option's text. |
| allowNewValue | boolean | AllowNewValueOptions default: false | Whether typed text that matches no option can be committed, offered as an extra dropdown option. In a multiple combobox, committed new values list as selected leading options, so they can be deselected like any other value. |
| clearable | boolean | { onClear: () => void; } | Whether the combobox can be cleared, showing the clear button; a single-select combobox that is not required is always clearable. Pair with invalid to style the emptied field as an error. |
ComboboxItem
The shape of items entries, optionally wrapped in { id, label, items }
groups. suffix, showOnlyButton, and selectedTone apply only with
multiple.
| Prop | Type | Description |
|---|---|---|
| value | string | What onChange receives when the option is selected |
| text | string | Visible label, also the text matched by filtering |
| disabled | boolean | Disables the option in the dropdown |
| selectedStyle | "checkbox" | "tick" | "highlight" | How a selected item is shown |
| suffix | ReactNode | Optional content aligned to the right of the item in the dropdown |
| showOnlyButton | boolean | Show an "Only" button while the item is hovered, which sets the selection to just this item. It renders in the suffix position, replacing suffix while visible. |
| selectedTone | "neutral" | "brand" | "error" | Tone of the selected indicator, when it differs from tone |
AllowNewValueOptions
The object form of allowNewValue.
| Prop | Type | Description |
|---|---|---|
| alwaysShowOption | boolean | If left undefined shows the option when the input does not match an enabled option |
| renderOption | ((input: string) => ReactNode) | Customize how the option looks |
PrefixOrSuffix
The object shape prefix and suffix take.
| Prop | Type | Description |
|---|---|---|
| iconName | "function" | "filter" | "at" | "circle" | … 137 more | Fills the slot with a design-system icon — a slot takes exactly one of iconName, text, or content. |
| text | string | Fills the slot with a short text label, such as a unit or protocol. |
| content | ReactNode | Fills the slot with arbitrary content. |
| onClick | (() => void) | (() => void) | Makes the slot a button. |
| disabled | boolean | Disables just the slot's button. |
| variant | "subtle" | "default" default: "default" | subtle blends the slot into the field, with no divider from the input text. |
Was this page helpful?