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.
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>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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
| Prop | Type | Default | Description |
|---|---|---|---|
options | ComboboxOption[] | — | The options list |
multiple | boolean | false | Multi-select mode; determines the types of the value props |
value | string | null (string[] when multiple) | — | Controlled selection; uncontrolled when omitted |
defaultValue | string | 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 |
inputValue | string | — | 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 |
creatable | boolean | false | When 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 |
placeholder | string | "搜尋或選擇…" (Search or select…) | Input placeholder (hidden in multiple mode once chips exist) |
emptyText | string | "找不到符合的項目" (No matching items) | Text shown when nothing matches |
maxVisibleChips | number | — | In multiple mode, the maximum number of chips shown; the rest collapse into "+N" |
open | boolean | — | Controlled open state of the list |
defaultOpen | boolean | false | Initial open state in uncontrolled mode |
onOpenChange | (open: boolean) => void | — | Called when the list opens or closes |
disabled | boolean | false | Disable the whole component |
duration | number | 100 | List 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 |
sideOffset | number | 4 | Distance between the list and the input (px) |
id | string | — | id of the input, so an external label can point at it with htmlFor |
aria-label | string | — | Accessible name when there is no visible label |
className | string | — | Forwarded to the outermost container |
ComboboxOption
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | Option value; must be unique within options |
label | string | — | Display text, also what the default matcher compares against |
group | string | — | Group name; options with the same name are listed under one heading, in order of first appearance |
disabled | boolean | false | Shown but not selectable; keyboard navigation skips it |
keywords | string[] | — | 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
isComposingandkeyCode 229first (Safari sends keydown aftercompositionend), 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
bodywithposition: 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 triggersonCreate
Accessibility
- The input has
role="combobox"witharia-expanded,aria-controls, andaria-autocomplete="list", andaria-activedescendantpoints at the highlighted option; focus always stays in the input and clicking the list never blurs it - The list is
role="listbox"(witharia-multiselectablein multiple mode); options arerole="option"witharia-selectedandaria-disabled; groups are wrapped inrole="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-labelof "Remove xxx"; the result count is announced through anaria-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
Form Field
A form field wrapper React component that ties together label, hint, error and prefix/suffix addons; errors expand and collapse by height without layout jumps, addons share the input's focus ring, and a render prop wraps any control.
Dial Knob
A rotary knob input: an SVG tick ring, a subtle nudge at detents, a spring-settled pointer and a rolling readout, with wheel and arrow-key support.