Combobox

A single field combining a text input with a filtered list of options.

Installation

  1. Install the package.
npm install @hashintel/ds-components
  1. 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 multiple to select several values from one list, rendered as removable chips inside the field.
  • Do: Use allowNewValue when 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…"
    />
  );
};

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

Amara Okafor, Dev Patel, Inés Fernández, Jonas Weber

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 value match or a case-insensitive text match. 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 items still render and list as leading dropdown options so they can be deselected. This keeps allowNewValue selections and stale data removable.

Content

  • Write option text as 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 noMatchMessage name 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 name and required; 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 FormField with a Label, or pass aria-label. A placeholder is not a label.
  • Keep renderItem output textual and consistent with the option's text, 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:

KeyAction
EnterSelects the highlighted option; with nothing highlighted, commits the first matched option (or the input text with allowNewValue).
EscapeReverts 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

PropTypeDescription
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)
onClick
MouseEventHandler<Element>
size
"xxs" |⁠ "xs" |⁠ "sm" |⁠ "md" |⁠ "lg"
default: "md"
placeholder
string
prefix
PrefixOrSuffix
suffix
PrefixOrSuffix
variant
"subtle" |⁠ "default"
default: "default"
type
HTMLInputTypeAttribute
loading
boolean
disabled
boolean
tabIndex
number
autoFocus
boolean |⁠ "never"
onKeyDown
KeyboardEventHandler<Element>
onFocus
FocusEventHandler<HTMLInputElement>
onBlur
FocusEventHandler<HTMLInputElement>
width
"xs" |⁠ "sm" |⁠ "md" |⁠ "lg" |⁠ "fullWidth" |⁠ "fitContent"
default: "fullWidth"
required
boolean
name
string
invalid
boolean
inputMode
"search" |⁠ "text" |⁠ "none" |⁠ "email" |⁠ … 4 more
readonly
boolean
align
"left" |⁠ "right" |⁠ "center"
connectToLeftInput
boolean
connectToRightInput
boolean
styledValue
ReactNode
showEditIcon
boolean
maxLength
number
minLength
number
pattern
string
spellcheck
boolean
onChangeInput
((value: string) => void)
filterAlgorithm
"none" |⁠ "startsWith" |⁠ "contains" |⁠ ((input: string, text: string, value: string) => boolean)
default: "contains"
noMatchMessage
((input: string) => ReactNode)
emptyState
ReactNode
multiple
boolean
default: false
maxItems
number
overflow
"truncate" |⁠ "scroll" |⁠ "summary"
default: "scroll"
clearInputOnSelect
boolean
default: true
renderSelectedAll
((values: TValue[]) => ReactNode)
items
readonly ItemOrGroup<ComboboxItem<TValue>>[] |⁠ readonly ItemOrGroup<MultiComboboxItem<TValue>>[]
default: []
renderItem
((value: TValue) => ReactNode) |⁠ ((value: TValue) => ReactNode)
renderSelectedItem
((value: TValue) => ReactNode) |⁠ ((value: TValue) => ReactNode)
allowNewValue
boolean |⁠ AllowNewValueOptions
default: false
clearable
boolean |⁠ { onClear: () => void; }

ComboboxItem

The shape of items entries, optionally wrapped in { id, label, items } groups. suffix, showOnlyButton, and selectedTone apply only with multiple.

PropTypeDescription
value
string
text
string
disabled
boolean
selectedStyle
"checkbox" |⁠ "tick" |⁠ "highlight"
suffix
ReactNode
showOnlyButton
boolean
selectedTone
"neutral" |⁠ "brand" |⁠ "error"

AllowNewValueOptions

The object form of allowNewValue.

PropTypeDescription
alwaysShowOption
boolean
renderOption
((input: string) => ReactNode)

PrefixOrSuffix

The object shape prefix and suffix take.

PropTypeDescription
iconName
"function" |⁠ "filter" |⁠ "at" |⁠ "circle" |⁠ … 137 more
text
string
content
ReactNode
onClick
(() => void) |⁠ (() => void)
disabled
boolean
variant
"subtle" |⁠ "default"
default: "default"

Was this page helpful?

On this page