Inputs
Combobox
A searchable dropdown selector — single or multi-select, grouped, creatable, with keyboard-driven filtering.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
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 div
(role="combobox"); props extend HTMLAttributes<HTMLDivElement> minus
value/onChange/children and spread onto the trigger (onClick / onKeyDown
are composed with the built-in handlers); className goes to the wrapper.
- Accessible name: role="combobox" takes no name from its content, so the
trigger falls back to aria-label={placeholder}. Pass aria-label (or
aria-labelledby, which suppresses the fallback) to name it after the
field — a visible <label htmlFor> does not name a div.
- 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.
- Keys that belong to an IME composition (nativeEvent.isComposing, or
keyCode 229 in Safari) are ignored by the search input's handler, so the
Enter that confirms Chinese / Japanese input never commits a row or
creates a half-typed tag.
- 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` (and the
row count) reads the already-mounted popup's real offsetHeight against the
trigger's getBoundingClientRect and the nearest clipping ancestor (first
ancestor with non-visible overflow, else the viewport), then calls
setState inside that callback — the effect body itself never calls
setState synchronously. The same pass caps the panel's maxHeight to the
room on the chosen side (floor 140px), so the list scrolls instead of
being clipped by an overflow:hidden card.
- 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 — the trigger is a
divwithrole="combobox"rather than a<button>, precisely so each chip can carry a real<button>remover; a button inside a button is invalid HTML, androle="button"on the trigger would hide the removers from assistive tech.