rapidUI

Combobox

Composable combobox with a compound API built on Popover and Command.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/combobox.json

Installs to @/components/ui/combobox.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0

Add the components it builds on: button, input-group.

Copy the source into your project:

components/ui/combobox.tsx
'use client'

import { Combobox as ComboboxPrimitive } from '@base-ui/react/combobox'
import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
import { cn } from '@/lib/utils'

// Lets the parts hide controls that can't be used while the combobox is read-only (clear, chip remove)
const ComboboxReadOnlyContext = React.createContext(false)

function Combobox<Value, Multiple extends boolean | undefined = false, Item = Value>(
	props: ComboboxPrimitive.Root.Props<Value, Multiple, Item>,
) {
	return (
		<ComboboxReadOnlyContext.Provider value={props.readOnly ?? false}>
			<ComboboxPrimitive.Root {...props} />
		</ComboboxReadOnlyContext.Provider>
	)
}

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 aria-hidden="true" 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" />}
			className={cn(className)}
			{...props}
		>
			<XIcon aria-hidden="true" className="pointer-events-none" />
		</ComboboxPrimitive.Clear>
	)
}

function ComboboxInput({
	className,
	children,
	disabled = false,
	showTrigger = true,
	showClear = false,
	triggerLabel = 'Show options',
	...props
}: ComboboxPrimitive.Input.Props & {
	showTrigger?: boolean
	showClear?: boolean
	/** Accessible name of the chevron button, which shows only an icon. Translate it in your app. */
	triggerLabel?: string
}) {
	const readOnly = React.useContext(ComboboxReadOnlyContext)

	return (
		<InputGroup className={cn('w-auto', className)}>
			<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
			<InputGroupAddon align="inline-end">
				{showTrigger && (
					<InputGroupButton
						size="icon-xs"
						variant="ghost"
						render={<ComboboxTrigger />}
						// Only a chevron icon, so without a name a screen reader announces just "button" (WCAG 4.1.2).
						aria-label={triggerLabel}
						data-slot="input-group-button"
						className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
						disabled={disabled}
					/>
				)}
				{showClear && !readOnly && <ComboboxClear disabled={disabled} />}
			</InputGroupAddon>
			{children}
		</InputGroup>
	)
}

function ComboboxContent({
	className,
	side = 'bottom',
	sideOffset = 6,
	align = 'start',
	alignOffset = 0,
	anchor,
	...props
}: ComboboxPrimitive.Popup.Props &
	Pick<ComboboxPrimitive.Positioner.Props, 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'>) {
	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 rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *: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:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
						className,
					)}
					{...props}
				/>
			</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 pr-8 pl-1.5 text-sm 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 right-2 flex size-4 items-center justify-center" />}
			>
				<CheckIcon aria-hidden="true" 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(
				'hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex',
				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(
				// Same look as Input: border, hover, solid focus ring, invalid, disabled and the shared read-only style
				'flex min-h-10 flex-wrap items-center gap-1 rounded-lg border border-input bg-background bg-clip-padding px-2.5 py-1 text-sm transition-colors hover:border-primary/90 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 has-[[data-slot=combobox-chip-input]:read-only]:cursor-default has-[[data-slot=combobox-chip-input]:read-only]:border-dashed has-[[data-slot=combobox-chip-input]:read-only]:bg-muted/50 has-[[data-slot=combobox-chip-input]:read-only]:hover:border-input has-[[data-slot=combobox-chip-input]:disabled]:cursor-not-allowed has-[[data-slot=combobox-chip-input]:disabled]:bg-input/50 has-[[data-slot=combobox-chip-input]:disabled]:opacity-50 has-[[data-slot=combobox-chip-input]:disabled]:hover:border-input dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40 dark:has-[[data-slot=combobox-chip-input]:disabled]:bg-input/80',
				className,
			)}
			{...props}
		/>
	)
}

function ComboboxChip({
	className,
	children,
	showRemove = true,
	...props
}: ComboboxPrimitive.Chip.Props & {
	showRemove?: boolean
}) {
	const readOnly = React.useContext(ComboboxReadOnlyContext)

	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]:pr-0',
				className,
			)}
			{...props}
		>
			{children}
			{showRemove && !readOnly && (
				<ComboboxPrimitive.ChipRemove
					render={<Button variant="ghost" size="icon-xs" />}
					className="-ml-1 opacity-50 hover:opacity-100"
					data-slot="combobox-chip-remove"
				>
					<XIcon aria-hidden="true" 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 bg-transparent outline-none placeholder:text-muted-foreground read-only:cursor-default disabled:cursor-not-allowed',
				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,
}

An input with a filterable list of options, built on the Base UI Combobox. Pick one value, or several with multiple (shown as removable chips). The input has the same look as Input and InputGroup, including the solid focus ring, the aria-invalid state and the shared read-only style.

Features

  • Type to filter; the list is built from the items you pass to Combobox
  • Single or multiple selection (multiple), with chips for the selected values
  • Items can be strings or objects ({ value, label } is picked up automatically, or use itemToStringLabel)
  • Groups with labels and separators
  • Optional clear button (showClear) and trigger button (showTrigger)
  • readOnly and disabled states
  • Keyboard and screen reader support from Base UI (role="combobox" input, listbox popup)

Usage

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Field, FieldLabel } from "@/components/ui/field"

const departments = ["Planning and Building Office", "Social Services", "Elderly Care"]

<Field>
  <FieldLabel htmlFor="department">Responsible department</FieldLabel>
  <Combobox items={departments}>
    <ComboboxInput id="department" placeholder="Select a department" />
    <ComboboxContent>
      <ComboboxEmpty>No department found.</ComboboxEmpty>
      <ComboboxList>
        {(department: string) => (
          <ComboboxItem key={department} value={department}>
            {department}
          </ComboboxItem>
        )}
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</Field>

Examples

Default

A filterable list of departments, labelled with Field and FieldLabel.

Controlled

Assigning a case officer inside a Card. The value is held in state, items are objects with an avatar and a workload hint, and showClear adds a clear button.

Items shaped { value, label } show their label in the input. The value is the item itself.

const [officer, setOfficer] = useState<Officer | null>(null)

<Combobox items={officers} value={officer} onValueChange={setOfficer}>
  <ComboboxInput id="case-officer" placeholder="Select a case officer" showClear />
  <ComboboxContent>
    <ComboboxEmpty>No case officer found.</ComboboxEmpty>
    <ComboboxList>
      {(officer: Officer) => (
        <ComboboxItem key={officer.value} value={officer}>
          {officer.label}
        </ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxContent>
</Combobox>

With Groups

Grouped items with labels and separators.

Pass groups ({ value, items }) as items and render each with ComboboxGroup, ComboboxLabel and ComboboxCollection.

<Combobox items={facilityGroups}>
  <ComboboxInput id="facility" placeholder="Select a facility to book" />
  <ComboboxContent>
    <ComboboxEmpty>No facility found.</ComboboxEmpty>
    <ComboboxList>
      {(group: FacilityGroup, index: number) => (
        <ComboboxGroup key={group.value} items={group.items}>
          {index > 0 && <ComboboxSeparator />}
          <ComboboxLabel>{group.value}</ComboboxLabel>
          <ComboboxCollection>
            {(facility: string) => (
              <ComboboxItem key={facility} value={facility}>
                {facility}
              </ComboboxItem>
            )}
          </ComboboxCollection>
        </ComboboxGroup>
      )}
    </ComboboxList>
  </ComboboxContent>
</Combobox>

Loading

While the options are fetched, the input shows a spinner, is marked aria-busy and the empty state says that the list is loading.

Multiple

Topics for email notifications, shown as chips. Backspace in the empty input removes the last chip.

Put the chips and a ComboboxChipsInput in ComboboxChips, and anchor the popup to it with useComboboxAnchor.

import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
  useComboboxAnchor,
} from "@/components/ui/combobox"

const anchor = useComboboxAnchor()
const [topics, setTopics] = useState<string[]>([])

<Combobox multiple items={allTopics} value={topics} onValueChange={setTopics}>
  <ComboboxChips ref={anchor}>
    <ComboboxValue>
      {(selected: string[]) => (
        <>
          {selected.map((topic) => (
            <ComboboxChip key={topic}>{topic}</ComboboxChip>
          ))}
          <ComboboxChipsInput id="topics" placeholder={selected.length === 0 ? "Choose topics…" : undefined} />
        </>
      )}
    </ComboboxValue>
  </ComboboxChips>
  <ComboboxContent anchor={anchor}>
    <ComboboxEmpty>No topic found.</ComboboxEmpty>
    <ComboboxList>
      {(topic: string) => (
        <ComboboxItem key={topic} value={topic}>
          {topic}
        </ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxContent>
</Combobox>

Multiple, preselected

A case team with two officers already selected, inside a card.

Multiple, invalid

aria-invalid on the ComboboxChipsInput gives the chips field a destructive border and ring until a value is chosen.

Read Only

readOnly on Combobox shows the value with the shared read-only style (dashed border, muted background). The list can be opened and browsed, but the value can't change; the clear button and the chips' remove buttons are hidden, and the input is exposed with aria-readonly.

Disabled

A disabled combobox, with a description that explains how to enable it.

Invalid

Set aria-invalid on ComboboxInput and explain the error in a FieldError.

Props

Combobox (Root)

Base UI Combobox.Root. The most used props:

  • items - The options (or groups of options) to filter and render
  • value / defaultValue / onValueChange - The selected value (an array when multiple); null when nothing is selected
  • multiple?: boolean - Allow several values
  • inputValue / onInputValueChange - Control the text in the input
  • itemToStringLabel / itemToStringValue / isItemEqualToValue - For object items that aren't { value, label }
  • open / onOpenChange - Control the popup
  • readOnly?: boolean - Show the value without letting it change
  • disabled?: boolean - Disable the combobox
  • name, required - Form integration

ComboboxInput

The input, wrapped in an InputGroup. Takes the input props plus:

  • showTrigger?: boolean - Show the chevron button that toggles the list (default true)
  • showClear?: boolean - Show a button that clears the value (default false)
  • children - Extra InputGroupAddons

ComboboxContent

The popup. side, align (default start), sideOffset, alignOffset and anchor (use with useComboboxAnchor for chips).

ComboboxList

Renders the options; pass a function (item, index) => ReactNode to render each item.

ComboboxItem

  • value - The item's value (required)
  • disabled?: boolean

ComboboxGroup, ComboboxLabel, ComboboxCollection, ComboboxSeparator

Group options (ComboboxGroup items), label a group, render a group's items and divide groups.

ComboboxChips, ComboboxChip, ComboboxChipsInput, ComboboxValue

Multiple selection: the chips container (styled like Input), a chip (showRemove, default true), the input inside the chips, and ComboboxValue to render the selected values.

Accessibility

  • The input has role="combobox" with aria-expanded, aria-controls and aria-activedescendant; the popup is a listbox and the selected options are aria-selected
  • Label the input with FieldLabel htmlFor (or aria-label) and link descriptions and errors with aria-describedby
  • Keyboard:
    • ↓ / ↑: open the list and move through the options
    • Enter: select the highlighted option
    • Esc: close the list
    • Backspace (multiple, empty input): remove the last chip; ← / → move between chips
  • readOnly sets readOnly and aria-readonly on the input
  • The chevron button is named by triggerLabel on ComboboxInput (default "Show options"); translate it, as the button shows only an icon
  • Select
  • Dropdown Menu
  • Command
  • Input Group