MultiCombobox

Searchable multi-select input. Composes Popover + Command (cmdk) + a styled trigger. Trigger shows '{n} selected'; each option exposes aria-selected.

Installation

pnpm
pnpm dlx @hex-core/cli add multi-combobox

Usage

tsx
import { MultiCombobox } from "@/components/ui/multi-combobox"

Tag picker

Multi-select with a small static list and chip count

tsx
import { useState } from "react";
import { MultiCombobox } from "@/components/ui/multi-combobox";

const tags = [
  { value: "bug", label: "Bug" },
  { value: "feature", label: "Feature" },
  { value: "question", label: "Question" },
  { value: "docs", label: "Documentation" },
];

export function Example() {
  const [picks, setPicks] = useState<string[]>([]);
  return (
    <MultiCombobox
      options={tags}
      value={picks}
      onChange={setPicks}
      placeholder="Pick tags"
      aria-label="Tags"
    />
  );
}

Capped selection

Limit the number of items the user can pick

tsx
import { useState } from "react";
import { MultiCombobox } from "@/components/ui/multi-combobox";

export function Example() {
  const [picks, setPicks] = useState<string[]>([]);
  return (
    <MultiCombobox
      options={tags}
      value={picks}
      onChange={setPicks}
      maxSelected={3}
      aria-label="Up to 3 tags"
    />
  );
}

API Reference

PropTypeDefaultDescription
optionsrequired
objectArray of { value: string, label: string, disabled?: boolean }
value
objectControlled selected values (string[])
onChange
functionCallback when the user toggles an option: (values: string[]) => void
placeholder
stringSelect…Text shown on the trigger when nothing is selected
searchPlaceholder
stringSearch…Placeholder for the filter input
emptyText
stringNo results found.Shown inside the list when the search has no matches
maxSelected
numberSoft cap on selections — once reached, unselected options become aria-disabled and clicks are ignored
closeOnSelect
booleanfalseClose the popover after every pick. Default false matches multi-select UX (Linear/Notion).
disabled
booleanfalseDisable the trigger
aria-label
stringAccessible label — required when no adjacent visible label is used
aria-labelledby
stringId of an external visible label that names the combobox

AI Guidance

When to use

Use to pick multiple items from a list of >~8 options where users benefit from typing to narrow. Common for tags, recipients, filters. Trigger shows count, each option exposes aria-selected.

When not to use

Don't use for single-select (use Combobox). Don't use for free-text entry (use Input or a tag input). Don't use for very large lists (>500 options) without server-side filtering — cmdk filters in-memory.

Common mistakes

  • Passing duplicate option values — Set-based selection treats them as one
  • Two options with identical labels — cmdk dedupes by the Item's filter value (the label here), so one will be dropped from the list
  • Forgetting that value is string[] not string — passing a single string breaks Array iteration
  • Setting closeOnSelect={true} for a power-user picker — multi-select normally stays open until the user dismisses
  • Missing aria-label / aria-labelledby — role='combobox' does not derive its name from contents, so the trigger has no accessible name without one
  • Relying on maxSelected to enforce business rules — the cap is a UX hint; always validate the array length on submit

Accessibility

Trigger has role='combobox' + aria-expanded + aria-haspopup='listbox'. aria-controls points at the inner CommandList only when open. Each option carries aria-selected; capped/disabled options carry aria-disabled. A visually-hidden aria-live='polite' region inside the trigger announces selection-count changes.

Token budget: 1100