Inputs

Combobox

A searchable dropdown selector — single or multi-select, grouped, creatable, with keyboard-driven filtering.

Preview in your theme

Loading preview…

"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 <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) 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 — since the trigger itself is a real <button>, per-chip removal uses a role="button" span instead of a nested <button>, which HTML forbids inside another button.

On This Page