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
"use client"

import * as React from "react"
import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Nearest ancestor that would clip the panel. The viewport is not always the
 * limit: docs cards, sidebars and modals routinely set overflow:hidden, and a
 * popover measured against the viewport happily renders straight into that
 * clip — options end up drawn but unclickable.
 */
function clippingRect(el: HTMLElement): { top: number; bottom: number } {
  let node: HTMLElement | null = el.parentElement

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/combobox.json

Prompt

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) and navRows (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 a value slug or decides how the new item merges into items, leaving that judgment with the caller who actually owns the data.
  • Cap as a visible frontier, not a hidden wall — hitting maxSelected disables 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 div with role="combobox" rather than a <button>, precisely so each chip can carry a real <button> remover; a button inside a button is invalid HTML, and role="button" on the trigger would hide the removers from assistive tech.

On This Page