Inputs

Combobox

A type-to-filter picker that never filters or selects mid-composition for CJK input, with an anchored list that flips at edges, a sliding highlight, and removable chips in multi-select mode.

This is a WebberUI Pro component

The install command below needs a licence to run; without one it returns 401.

How to install Pro components →See the plans →

Once a dropdown has more than a handful of options, people want to type to find one. This Combobox treats CJK input as a first-class case: while someone types Zhuyin such as "ㄊㄞˊ", the list stays put instead of jumping around on half-typed syllables, and filtering only happens once a character is picked. Enter, arrow keys, and Backspace pressed mid-composition all go to the IME, so nothing is selected or removed by accident. The default matcher treats 臺 and 台 as the same character, and keywords let you add English names or aliases. The list is anchored below the input and flips above when there is no room, and the highlight slides between options. Switch on multiple and selected items become removable chips; creatable lets people add items that are not in the list.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add "https://webberui.com/r/combobox.en.json?t=<install token>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

100
Active edited usageDefault props
<Combobox/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add "https://webberui.com/r/combobox.en.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/combobox.

Usage

import { Combobox, type ComboboxOption } from "@/components/ui/combobox";

const cities: ComboboxOption[] = [
  { value: "taipei", label: "臺北市", group: "北部", keywords: ["Taipei"] },
  { value: "taichung", label: "臺中市", group: "中部", keywords: ["Taichung"] },
  { value: "kaohsiung", label: "高雄市", group: "南部", keywords: ["Kaohsiung"] },
];

// 單選:value 為 string | null
const [city, setCity] = React.useState<string | null>(null);
<Combobox options={cities} value={city} onValueChange={setCity} aria-label="配送縣市" />;

// 多選 + 可新增:value 為 string[]
const [tags, setTags] = React.useState<string[]>([]);
const [tagOptions, setTagOptions] = React.useState<ComboboxOption[]>([
  { value: "設計", label: "設計" },
  { value: "前端", label: "前端" },
]);
<Combobox
  multiple
  creatable
  options={tagOptions}
  value={tags}
  onValueChange={setTags}
  onCreate={(label) => setTagOptions((prev) => [...prev, { value: label, label }])}
  maxVisibleChips={3}
  aria-label="感興趣的主題"
/>;

Props

PropTypeDefaultDescription
optionsComboboxOption[]—The options list
multiplebooleanfalseMulti-select mode; determines the types of the value props
valuestring | null (string[] when multiple)—Controlled selection; uncontrolled when omitted
defaultValuestring | null (string[] when multiple)null / []Initial selection in uncontrolled mode
onValueChange(value: string | null) => void ((value: string[]) => void when multiple)—Called when the selection changes
inputValuestring—Controlled input text
onInputValueChange(value: string) => void—Called when the input text changes (including in-progress composition text)
filter(option: ComboboxOption, query: string) => boolean—Custom filter; by default the label and keywords are matched with a case-insensitive includes, treating 臺 as 台. Not called for an empty query
creatablebooleanfalseWhen nothing matches exactly, show a "Create" row at the bottom of the list
onCreate(label: string) => void—Called when the Create row is chosen; the new item is selected with its label as the value, so add it to options here
placeholderstring"搜尋或選擇…" (Search or select…)Input placeholder (hidden in multiple mode once chips exist)
emptyTextstring"找不到符合的項目" (No matching items)Text shown when nothing matches
maxVisibleChipsnumber—In multiple mode, the maximum number of chips shown; the rest collapse into "+N"
openboolean—Controlled open state of the list
defaultOpenbooleanfalseInitial open state in uncontrolled mode
onOpenChange(open: boolean) => void—Called when the list opens or closes
disabledbooleanfalseDisable the whole component
durationnumber100List pop-in duration (ms); also written to --wb-combobox-duration
side"bottom" | "top""bottom"Preferred side for the list; flips automatically when there is not enough room
sideOffsetnumber4Distance between the list and the input (px)
idstring—id of the input, so an external label can point at it with htmlFor
aria-labelstring—Accessible name when there is no visible label
classNamestring—Forwarded to the outermost container

ComboboxOption

FieldTypeDefaultDescription
valuestring—Option value; must be unique within options
labelstring—Display text, also what the default matcher compares against
groupstring—Group name; options with the same name are listed under one heading, in order of first appearance
disabledbooleanfalseShown but not selectable; keyboard navigation skips it
keywordsstring[]—Extra match terms, such as the English name "Taipei" or a variant character

The ComboboxOption, ComboboxSide, ComboboxProps, ComboboxSingleProps, and ComboboxMultipleProps types are also named exports.

How it works

  • No filtering mid-composition: the component tracks composition with compositionstart / compositionend. While composing, the input shows the Zhuyin as usual, but the text used for filtering is frozen at its pre-composition value and only updates with the final text once a character is picked — the list never flickers on partial syllables like "ㄊㄞ"
  • Keys mid-composition belong to the IME: Enter, ↑/↓, Backspace, and Esc all check isComposing and keyCode 229 first (Safari sends keydown after compositionend), and are passed through to the IME while composing, so nothing is selected and no chip is removed by accident
  • When it opens and closes: clicking the input, starting to type, pressing ↓, or clicking the chevron button opens the list; the first Esc closes it and the second clears the input; clicking outside or tabbing away closes it. In single mode the input shows the chosen label after selection; on blur, edited-but-unchosen text is restored to the selected label; clicking the selected option again deselects it
  • Positioning: the list is portaled to body with position: fixed, matches the width of the input frame (including the chip row in multiple mode), and flips above when there is no room below. Scrolling and resizing recompute the position, throttled with rAF, and the list stays hidden until first measured so it never flashes in the corner
  • Timing: the pop-in is fade + scale 0.95 + an 8px slide along the side, 100ms by default and controlled by duration; CSS transitions inside the list read --wb-combobox-duration, which you can override globally. The highlight slides on a shared-layoutId spring; chips spring in, and the remaining chips FLIP into place when one is removed
  • Creating: with creatable, if the typed text doesn't exactly match any existing label (again treating 臺 as 台), a Create row appears at the bottom of the list; Enter or a click triggers onCreate

Accessibility

  • The input has role="combobox" with aria-expanded, aria-controls, and aria-autocomplete="list", and aria-activedescendant points at the highlighted option; focus always stays in the input and clicking the list never blurs it
  • The list is role="listbox" (with aria-multiselectable in multiple mode); options are role="option" with aria-selected and aria-disabled; groups are wrapped in role="group" and named by their heading
  • ↑/↓ move the highlight cyclically and skip disabled options, Home/End jump to the first and last option, and Enter selects; in multiple mode, Backspace in an empty input removes the last chip
  • Every chip's remove button is a Tab-focusable button with an aria-label of "Remove xxx"; the result count is announced through an aria-live="polite" region ("N results" or the empty-state text)
  • When the user has "reduce motion" enabled, the list keeps only its fade (same duration) with no scale or slide; the highlight and chips jump straight into place without springs or FLIP reflow