Inputs
Combobox
A searchable dropdown selector — single or multi-select, grouped, creatable, with keyboard-driven filtering.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/combobox.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Combobox" component (lucide-react
Check, ChevronsUpDown, Loader2, Plus, X). No Radix, no popover library — the
dropdown is drawn and driven by the component itself, the same self-drawn
pattern this registry's PhoneInput already uses.
Contract
- Export a forwardRef component whose ref points at the trigger <button>;
props extend ButtonHTMLAttributes minus value/onChange/children.
- items: { value, label, description?, icon?, disabled?, group? }[].
- Controlled: value = string (single mode) | string[] (multiple mode), plus
onValueChange(next) receiving that same shape back.
- multiple? (default false); maxSelected? (multiple mode only — a hard cap:
unselected rows disable at the cap and a footer line switches from an
"N / max selected" counter to "Maximum N selected").
- creatable? + onCreate?(label) — shows a `Create "<query>"` row once the
trimmed query is non-empty and matches no existing label exactly;
selecting it calls onCreate and leaves adding/selecting the new item to
the caller (the component never invents a value slug).
- placeholder?, searchPlaceholder?, emptyText?, loading?, disabled?,
className.
Behavior
- Trigger is a div with role="combobox" (deliberately NOT a <button>) carrying
aria-expanded, aria-controls while open, aria-haspopup="listbox", tabIndex 0
(-1 plus aria-disabled when disabled), and hand-wired activation: Enter /
Space / ArrowDown open, Escape closes. A div is required because the
multi-select chips contain real remove buttons — interactive content inside a
<button> is invalid HTML, and role="button" would make its children
presentational, hiding those controls from assistive tech.
Single mode shows the selected item's icon + label, or the placeholder when
nothing is selected. Multiple mode shows up to 2 chips then a "+N" pill; each
chip carries a real `<button type="button" aria-label="Remove <label>">` that
stopPropagation()s on click and keydown, so removing a chip never also toggles
the panel — and gets its role, name and keyboard activation for free.
- Exactly one element in the control has role="combobox": the trigger. The
popup's search field stays a plain text input with aria-controls +
aria-activedescendant + aria-autocomplete="list", so assistive tech announces
one combobox rather than two competing ones.
- Opening resets the query, focuses a search input inside the popup, and
highlights the first enabled row.
- Filtering is a case-insensitive subsequence match against `label` (so
"cmb" matches "Combobox"), computed together with grouping and the create
row in one memo, producing both the rendered rows (headers included) and
a flat keyboard-nav index list (headers excluded) from the same pass — the
two views can never drift apart.
- Groups: items sharing `group` cluster under one `role="presentation"`
heading, in first-appearance order; a group with zero matches after
filtering simply doesn't render.
- Keyboard on the search input: ArrowUp / ArrowDown move a roving highlight
and skip disabled rows, Home/End jump to the first/last enabled row, Enter
commits the highlighted row (option or create), Escape closes and
refocuses the trigger. Tab isn't special-cased — the panel closes via the
same focus-out mechanism that also backs the click-outside listener.
- The highlighted index is *derived*, not hand-synced: whenever the stored
index stops pointing at an enabled row (typing shrank the list, a cap just
kicked in), render falls back to the first enabled row that frame — so the
search input's onChange stays a plain setQuery, nothing else.
- Multiple mode keeps the popup open after a selection (so the user can keep
picking) and refocuses the search input; single mode closes and refocuses
the trigger.
- loading: the row list is replaced by a single spinner row; nothing is
selectable while it shows, so there's no stale selection from a list the
user can't see.
- Panel flips above the trigger when it won't fit below: a
requestAnimationFrame scheduled from an effect keyed on `open` reads the
already-mounted popup's real offsetHeight against the trigger's
getBoundingClientRect and the viewport, then calls setState inside that
callback — the effect body itself never calls setState synchronously.
- Click-outside (a document pointerdown listener that only exists while
open) and Tab-out (blur whose relatedTarget sits outside the root) both
close the panel; a null relatedTarget (a press on a non-focusable row) is
left for the click-outside listener to handle instead.
Rendering & styling
- Field: h-9 rounded-md border border-input, a focus-visible ring on the
trigger, border-ring + ring-ring/50 while open. Semantic tokens only:
bg-secondary/text-secondary-foreground for chips, bg-muted for the "+N"
pill, bg-accent/text-accent-foreground for the highlighted row,
bg-popover/text-popover-foreground for the panel, text-muted-foreground
for placeholders, descriptions and counters.
- Long labels and descriptions truncate (`truncate` plus `min-w-0` on flex
children) instead of wrapping or widening the trigger.
- Loader2 spinners carry motion-reduce:animate-none; nothing else in the
component loops, so no further reduced-motion branching is needed.
- cn() merges the consumer className onto the relative wrapper.
Customization levers
- Chip visibility: CHIP_VISIBLE_LIMIT (currently 2) is the only knob for how
many chips show before collapsing into "+N" — raise it for wider triggers.
- Filter algorithm: swap the subsequence matcher for a fuzzy/Levenshtein
scorer, or extend it to also search `description`, without touching
selection state.
- Disabled-row policy: the maxSelected cap and per-item `disabled` both flow
through one `rowDisabled` expression inside the rows memo — add more
conditions there (e.g. a role check) and keyboard nav skips them for free.
- Panel width/placement: the popup is `w-full` and only flips vertically;
add a horizontal-overflow check the same way (measure, then setState
inside the rAF callback) if triggers can sit near a viewport edge.
- Empty vs loading copy: `emptyText` and the "Loading…" row are the two
strings to localize; both are plain props/literals, no i18n library
assumed.Concepts
- One memo, two shapes — filtering, grouping and the create row all compute together into
renderRows(for JSX, headers included) andnavRows(flat, keyboard-nav only), so the two views of the list can never disagree with each other. - Derived highlight, not synchronized state — the roving index is recomputed at render from whatever the list currently is; typing never needs a matching "reset the highlight" call, it simply stops being valid and falls back to the first enabled row.
- Owns nothing it selects — the create row hands a bare label to
onCreate; the component never invents avalueslug or decides how the new item merges intoitems, leaving that judgment with the caller who actually owns the data. - Cap as a visible frontier, not a hidden wall — hitting
maxSelecteddisables the remaining rows in place (with a counter) instead of removing them, so users can see what they can't add yet and why. - Flip without a portal — placement is decided by measuring the already-mounted popup in a
requestAnimationFrame, one frame after opening, the same no-portal approach this registry's other pickers use. - Chip removal without nested buttons — since the trigger itself is a real
<button>, per-chip removal uses arole="button"span instead of a nested<button>, which HTML forbids inside another button.