Skip to contentVibraUI
Inputs & filters

Combobox

A text field that filters a list as you type, with chips for choosing several.

Vibra draws the popup as the kit's elev-1 floating layer and fades it in over --duration-base without shadcn's zoom, sliding 4px from its side rather than 8; options are set in the 13px label register. The field itself is shadcn's input group. The popup reads out how many options the typing leaves ("12 results"; resultsLabel changes the words) and keeps its empty message in the page, so an empty list is heard as well; Base UI's icon buttons are named — the list button beside the field "Show options", the clear button "Clear", each chip's cross "Remove" and the chip's words — and the list button is out of the tab order, since the field opens the list from the keyboard. An option's padding and its check, a chip's padding and its cross, and the field's own buttons all sit on the inline axis, so on a right-to-left page the check lands at the end of an option's words and a chip's cross at the end of its name. The chips field stands on the card plane in both themes, as Input does.

Install

npx shadcn@latest add @vibra/combobox

Needs the @vibra registry in your components.json — set it up once.

Examples

Grouped

Metrics under where they come from; typing filters every group at once, and an emptied group takes its heading with it.

Two lines, with a mark

Accounts drawn with their marks and a line that tells similar names apart; the field takes the name alone.

Long list, counted

Forty-eight countries that scroll under the panel's edge, and a foot that says how many the typing has left.

Several, as chips

multiple with ComboboxChips: a chip per teammate, each cross named for its chip, and Backspace takes the last one off.

A button with the search inside

The trigger shows the choice and is the combobox; the field inside the panel only filters.

Searching the server

filter={null} with the answers as items: the newest question wins, and the panel says it is searching where the rows will be.

Create what isn't there

Typing a label the list lacks adds a last option that creates it and keeps it for next time.

Required, with an error

Typing is not choosing: sending with nothing chosen marks the field, points it at the message and hands the focus back.

Unavailable options, with the reason

Agents who can't take the ticket stay in the list with the reason in their row; the arrow keys reach them and Enter passes them by.

Props

PropTypeDefaultDescription
itemsT[] | { value: string; items: T[] }[]—The options, flat or in groups; the root filters them as the reader types and the popup counts what is left.
multiplebooleanfalseSeveral choices, one ComboboxChip each inside ComboboxChips; the value is an array.
filternull | ((item, query) => boolean)—null when the server does the filtering: pass its answers as items.
ComboboxInput.showTriggerbooleantrueDraws the chevron button that opens the list: named, and out of the tab order.
ComboboxInput.showClearbooleanfalseDraws a clear button once something is chosen.
ComboboxChip.showRemovebooleantrueGives each chip of a multiple combobox its own remove button, named for the chip.
ComboboxContent.resultsLabel(count: number) => string"12 results"The words read out for how many options are left; silent when none are, which ComboboxEmpty says.
ComboboxItem.disabledbooleanfalseDims the option and refuses it; the arrow keys still reach it, so a reason in its words is heard.

Dependencies

Source

components/ui/combobox.tsx
"use client"

import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"

import { Button } from "@/components/ui/button"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"

const Combobox = ComboboxPrimitive.Root

function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
  return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}

function ComboboxTrigger({
  className,
  children,
  ...props
}: ComboboxPrimitive.Trigger.Props) {
  return (
    <ComboboxPrimitive.Trigger
      data-slot="combobox-trigger"
      className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
      {...props}
    >
      {children}
      <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
    </ComboboxPrimitive.Trigger>
  )
}

function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
  return (
    <ComboboxPrimitive.Clear
      data-slot="combobox-clear"
      render={<InputGroupButton variant="ghost" size="icon-xs" />}
      // Base UI's clear button has no name of its own; a caller's wins.
      aria-label="Clear"
      className={cn(className)}
      {...props}
    >
      <XIcon className="pointer-events-none" />
    </ComboboxPrimitive.Clear>
  )
}

function ComboboxInput({
  className,
  children,
  disabled = false,
  showTrigger = true,
  showClear = false,
  ...props
}: ComboboxPrimitive.Input.Props & {
  showTrigger?: boolean
  showClear?: boolean
}) {
  return (
    <InputGroup className={cn("w-auto", className)}>
      <ComboboxPrimitive.Input
        // The group draws the field's focus. The input inside it would draw
        // Input's own outline as well — a second, square box cut into the
        // group's border — so it keeps its outline to itself.
        render={<InputGroupInput disabled={disabled} className="focus-visible:outline-none" />}
        {...props}
      />
      <InputGroupAddon align="inline-end">
        {showTrigger && (
          <InputGroupButton
            size="icon-xs"
            variant="ghost"
            render={<ComboboxTrigger />}
            data-slot="input-group-button"
            // The field is the one tab stop and opens the list from the
            // keyboard; the button is the pointer's way in, as in the ARIA
            // combobox pattern, and is named for a screen reader's browse mode.
            aria-label="Show options"
            tabIndex={-1}
            className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
            disabled={disabled}
          />
        )}
        {showClear && <ComboboxClear disabled={disabled} />}
      </InputGroupAddon>
      {children}
    </InputGroup>
  )
}

/** "1 result", "12 results": what the popup reads out as the typing narrows the list. */
function countResults(count: number) {
  return `${count} ${count === 1 ? "result" : "results"}`
}

/** A group from `items` holds its options in `items`; everything else is an option. */
function isGroup(entry: unknown): entry is { items: readonly unknown[] } {
  return typeof entry === "object" && entry !== null && Array.isArray((entry as { items?: unknown }).items)
}

/**
 * Reads out how many options the typing has left, from the list the root
 * filtered — so it speaks only for a combobox given `items`. An empty list is
 * ComboboxEmpty's to announce, so this falls silent then.
 */
function ComboboxResultsStatus({ label }: { label: (count: number) => string }) {
  const filtered = ComboboxPrimitive.useFilteredItems() as readonly unknown[]
  const count = filtered.reduce<number>((total, entry) => total + (isGroup(entry) ? entry.items.length : 1), 0)
  return (
    <ComboboxPrimitive.Status data-slot="combobox-status" className="sr-only">
      {count > 0 ? label(count) : null}
    </ComboboxPrimitive.Status>
  )
}

function ComboboxContent({
  className,
  children,
  side = "bottom",
  sideOffset = 6,
  align = "start",
  alignOffset = 0,
  anchor,
  resultsLabel = countResults,
  ...props
}: ComboboxPrimitive.Popup.Props &
  Pick<
    ComboboxPrimitive.Positioner.Props,
    "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
  > & {
    /** The words read out for how many options are left; "12 results" by default. */
    resultsLabel?: (count: number) => string
  }) {
  return (
    <ComboboxPrimitive.Portal>
      <ComboboxPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        anchor={anchor}
        className="isolate z-50"
      >
        <ComboboxPrimitive.Popup
          data-slot="combobox-content"
          data-chips={!!anchor}
          className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden elev-1 text-popover-foreground data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-left-1 data-[side=inline-start]:slide-in-from-right-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)", className )}
          {...props}
        >
          {children}
          <ComboboxResultsStatus label={resultsLabel} />
        </ComboboxPrimitive.Popup>
      </ComboboxPrimitive.Positioner>
    </ComboboxPrimitive.Portal>
  )
}

function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
  return (
    <ComboboxPrimitive.List
      data-slot="combobox-list"
      className={cn(
        "no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
        className
      )}
      {...props}
    />
  )
}

function ComboboxItem({
  className,
  children,
  ...props
}: ComboboxPrimitive.Item.Props) {
  return (
    <ComboboxPrimitive.Item
      data-slot="combobox-item"
      className={cn(
        "relative flex w-full cursor-default items-center gap-2 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <ComboboxPrimitive.ItemIndicator
        render={
          <span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center" />
        }
      >
        <CheckIcon className="pointer-events-none" />
      </ComboboxPrimitive.ItemIndicator>
    </ComboboxPrimitive.Item>
  )
}

function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
  return (
    <ComboboxPrimitive.Group
      data-slot="combobox-group"
      className={cn(className)}
      {...props}
    />
  )
}

function ComboboxLabel({
  className,
  ...props
}: ComboboxPrimitive.GroupLabel.Props) {
  return (
    <ComboboxPrimitive.GroupLabel
      data-slot="combobox-label"
      className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
      {...props}
    />
  )
}

function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
  return (
    <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
  )
}

function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
  return (
    <ComboboxPrimitive.Empty
      data-slot="combobox-empty"
      className={cn(
        // Never display:none: Base UI's empty message is a live region, heard
        // only if it is already in the page when its words arrive.
        "w-full justify-center text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex group-data-empty/combobox-content:py-2",
        className
      )}
      {...props}
    />
  )
}

function ComboboxSeparator({
  className,
  ...props
}: ComboboxPrimitive.Separator.Props) {
  return (
    <ComboboxPrimitive.Separator
      data-slot="combobox-separator"
      className={cn("-mx-1 my-1 h-px bg-border", className)}
      {...props}
    />
  )
}

function ComboboxChips({
  className,
  ...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
  ComboboxPrimitive.Chips.Props) {
  return (
    <ComboboxPrimitive.Chips
      data-slot="combobox-chips"
      className={cn(
        "flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-card bg-clip-padding px-2.5 py-1 text-sm transition-colors has-[input:focus-visible]:focus-outline has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
        className
      )}
      {...props}
    />
  )
}

function ComboboxChip({
  className,
  children,
  showRemove = true,
  ...props
}: ComboboxPrimitive.Chip.Props & {
  showRemove?: boolean
}) {
  const labelId = React.useId()
  const removeId = React.useId()
  return (
    <ComboboxPrimitive.Chip
      data-slot="combobox-chip"
      className={cn(
        "flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pe-0",
        className
      )}
      {...props}
    >
      <span id={labelId} data-slot="combobox-chip-label">
        {children}
      </span>
      {showRemove && (
        <ComboboxPrimitive.ChipRemove
          render={<Button variant="ghost" size="icon-xs" />}
          className="-ms-1 opacity-50 hover:opacity-100"
          data-slot="combobox-chip-remove"
          // "Remove Hiro Tanaka": the button's own word, then the chip's.
          id={removeId}
          aria-label="Remove"
          aria-labelledby={`${removeId} ${labelId}`}
        >
          <XIcon className="pointer-events-none" />
        </ComboboxPrimitive.ChipRemove>
      )}
    </ComboboxPrimitive.Chip>
  )
}

function ComboboxChipsInput({
  className,
  ...props
}: ComboboxPrimitive.Input.Props) {
  return (
    <ComboboxPrimitive.Input
      data-slot="combobox-chip-input"
      className={cn("min-w-16 flex-1 outline-none", className)}
      {...props}
    />
  )
}

function useComboboxAnchor() {
  return React.useRef<HTMLDivElement | null>(null)
}

export {
  Combobox,
  ComboboxInput,
  ComboboxContent,
  ComboboxList,
  ComboboxItem,
  ComboboxGroup,
  ComboboxLabel,
  ComboboxCollection,
  ComboboxEmpty,
  ComboboxSeparator,
  ComboboxChips,
  ComboboxChip,
  ComboboxChipsInput,
  ComboboxTrigger,
  ComboboxValue,
  useComboboxAnchor,
}