Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/combobox.jsonInstalls to @/components/ui/combobox.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0Add the components it builds on: button, input-group.
Copy the source into your project:
'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
itemsyou pass toCombobox - Single or multiple selection (
multiple), with chips for the selected values - Items can be strings or objects (
{ value, label }is picked up automatically, or useitemToStringLabel) - Groups with labels and separators
- Optional clear button (
showClear) and trigger button (showTrigger) readOnlyanddisabledstates- Keyboard and screen reader support from Base UI (
role="combobox"input,listboxpopup)
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 rendervalue/defaultValue/onValueChange- The selected value (an array whenmultiple);nullwhen nothing is selectedmultiple?: boolean- Allow several valuesinputValue/onInputValueChange- Control the text in the inputitemToStringLabel/itemToStringValue/isItemEqualToValue- For object items that aren't{ value, label }open/onOpenChange- Control the popupreadOnly?: boolean- Show the value without letting it changedisabled?: boolean- Disable the comboboxname,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 (defaulttrue)showClear?: boolean- Show a button that clears the value (defaultfalse)children- ExtraInputGroupAddons
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"witharia-expanded,aria-controlsandaria-activedescendant; the popup is alistboxand the selected options arearia-selected - Label the input with
FieldLabel htmlFor(oraria-label) and link descriptions and errors witharia-describedby - Keyboard:
↓/↑: open the list and move through the optionsEnter: select the highlighted optionEsc: close the listBackspace(multiple, empty input): remove the last chip;←/→move between chips
readOnlysetsreadOnlyandaria-readonlyon the input- The chevron button is named by
triggerLabelonComboboxInput(default "Show options"); translate it, as the button shows only an icon
Related Components
- Select
- Dropdown Menu
- Command
- Input Group