Combobox
A wrapper for Base UI Combobox with single and multi-select, chips, grouped options, async loading, custom label rendering, and create-new support.
—Installation
npx shadcn@latest add @glrk-ui/comboboxIf you haven't set up the prerequisites yet, check out Prerequest section.
Copy and paste the following code into your project.
type allowedPrimitiveT = string | number | boolean
type optionT = {
label: React.ReactNode
value: allowedPrimitiveT
className?: string
disabled?: boolean
}
type groupT = {
group: string
options: (allowedPrimitiveT | optionT)[]
className?: string
}
type optionsT = (allowedPrimitiveT | optionT | groupT)[]
type indicatorAtT = 'right' | 'left'
import { isValidElement, type ReactNode } from 'react'
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
return ['string', 'number', 'boolean'].includes(typeof value)
}
export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
if (typeof value !== 'string') return value
const trimmed = value.trim()
if (trimmed === 'true') return true
if (trimmed === 'false') return false
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
return trimmed
}
export function optionTypeChecker<T>(key: keyof T) {
return (option: any): option is T => !!option && typeof option === 'object' && key in option
}
export const isSeparator = (item: any) => item === '---'
export const isOption = optionTypeChecker<optionT>('value')
export const isGroup = optionTypeChecker<groupT>('group')
export const getValue = (item: allowedPrimitiveT | optionT) =>
typeof item === 'object' ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) =>
typeof item === 'object' ? item.label : `${item}`
export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
const val = getValue(item)
if (typeof val === 'boolean') return `key-${val}`
if (val === '---') return `---${i}`
return `${val}`
}
export function extractText(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children)
if (Array.isArray(node)) return node.map(extractText).join('')
return ''
}
'use client'
import * as React from 'react'
import { ChevronDownIcon, XIcon, CheckIcon, Loader2 } from 'lucide-react'
import { Combobox as ComboboxPrimitive } from '@base-ui/react'
import { cn, extractText, getKey, getLabel, getValue, isGroup, isOption, isSeparator } from '@/lib/utils'
const ComboboxRoot = ComboboxPrimitive.Root
function ComboboxValue(props: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxIcon({ className, ...props }: ComboboxPrimitive.Icon.Props) {
return (
<ComboboxPrimitive.Icon
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'[&_svg]:pointer-events-none',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</ComboboxPrimitive.Icon>
)
}
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn(className)}
{...props}
>
{children}
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'[&_svg]:pointer-events-none',
className,
)}
{...props}
>
<XIcon className="size-3.5" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<ComboboxPrimitive.InputGroup
data-slot="combobox-input-group"
className={cn(
'relative flex h-9 w-full items-center gap-0.5 rounded-md border border-input bg-transparent pl-3 pr-1 text-sm shadow-xs',
'transition-colors',
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
'has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20',
'dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40',
className,
)}
>
<ComboboxPrimitive.Input
disabled={disabled}
className={cn(
'h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
)}
{...props}
/>
<div className="flex shrink-0 items-center ml-auto">
{showClear && <ComboboxClear disabled={disabled} />}
{showTrigger && <ComboboxTrigger disabled={disabled}><ComboboxIcon /></ComboboxTrigger>}
</div>
{children}
</ComboboxPrimitive.InputGroup>
)
}
function ComboboxContent({
className,
side = 'bottom',
sideOffset = 4,
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}
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
'group/combobox-content relative overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
'max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin)',
'min-w-[max(var(--anchor-width),10rem)] data-[chips=true]:min-w-(--anchor-width)',
'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',
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
'data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2',
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
'max-h-(--available-height) overflow-y-auto overscroll-contain scroll-py-1 p-1',
'data-empty:hidden',
className,
)}
{...props}
/>
)
}
function ComboboxItem({ className, children, indicatorAt, ...props }: ComboboxPrimitive.Item.Props & { indicatorAt?: indicatorAtT }) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 text-sm outline-none',
'data-highlighted:bg-accent 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,
indicatorAt === 'right' ? 'pl-2 pr-8' : 'pl-8 pr-2',
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
className={cn(
'pointer-events-none absolute flex size-4 items-center justify-center text-foreground',
indicatorAt === 'right' ? 'right-2' : 'left-2',
)}
>
<CheckIcon className="size-3.5" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn('pb-1', 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 font-medium 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('text-center text-sm text-muted-foreground', 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-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-transparent pl-3 pr-1 py-1 text-sm shadow-xs',
'transition-colors',
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
'has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20',
'dark:bg-input/30 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 }) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
'flex h-5.5 w-fit items-center gap-1 rounded-sm bg-secondary px-1.5 text-xs font-medium text-secondary-foreground whitespace-nowrap',
'data-highlighted:bg-accent data-highlighted:text-accent-foreground',
'has-disabled:pointer-events-none has-disabled:opacity-50',
showRemove && 'pr-0.5',
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
data-slot="combobox-chip-remove"
className={cn(
'ml-0.5 flex size-4 items-center justify-center rounded-sm text-muted-foreground',
'transition-colors hover:bg-accent hover:text-foreground',
'[&_svg]:pointer-events-none',
)}
aria-label="Remove"
>
<XIcon className="size-3" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chips-input"
className={cn(
'min-w-16 flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
}
function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props) {
return (
<ComboboxPrimitive.Status
data-slot="combobox-status"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
type OptionItemProps = {
option: allowedPrimitiveT | optionT
className?: string
indicatorAt?: indicatorAtT
}
function OptionItem({ option, className, indicatorAt }: OptionItemProps) {
const value = getValue(option)
const label = getLabel(option)
const optCls = isOption(option) ? option.className : undefined
const disabled = isOption(option) ? option.disabled : undefined
return (
<ComboboxItem value={value} className={cn(className, optCls)} indicatorAt={indicatorAt} disabled={disabled}>
{label}
</ComboboxItem>
)
}
type OptionsBodyProps = {
item: optionsT[number]
index: number
itemCls?: string
groupCls?: string
indicatorAt?: indicatorAtT
}
function OptionsBody({ item, index, itemCls, groupCls, indicatorAt }: OptionsBodyProps) {
if (isGroup(item)) {
return (
<ComboboxGroup
items={item.options as (allowedPrimitiveT | optionT)[]}
className={cn(groupCls, item.className)}
>
<ComboboxLabel>{item.group}</ComboboxLabel>
<ComboboxCollection>
{(opt: allowedPrimitiveT | optionT, i) => (
<OptionItem
key={getKey(opt, i)}
option={opt}
className={cn(itemCls)}
indicatorAt={indicatorAt}
/>
)}
</ComboboxCollection>
</ComboboxGroup>
)
}
if (isSeparator(item)) {
return <ComboboxSeparator key={`sep-${index}`} />
}
return (
<OptionItem
key={getKey(item as allowedPrimitiveT | optionT, index)}
option={item as allowedPrimitiveT | optionT}
className={cn(itemCls)}
indicatorAt={indicatorAt}
/>
)
}
type ComboboxWrapperProps<
Value = unknown,
Multiple extends boolean | undefined = boolean | undefined,
> = Omit<ComboboxPrimitive.Root.Props<Value, Multiple>, 'items'> & {
items?: optionsT
isLoading?: boolean
placeholder?: string
emptyMessage?: string
triggerCls?: string
contentCls?: string
groupCls?: string
itemCls?: string
indicatorAt?: 'left' | 'right'
showTrigger?: boolean
showClear?: boolean
inputProps?: React.ComponentProps<'input'>
hideList?: boolean
renderValue?: (value: string, option: allowedPrimitiveT | optionT | undefined) => React.ReactNode
getItemLabel?: (value: string) => string
renderStatus?: React.ReactNode
renderEmpty?: React.ReactNode
}
function ComboboxWrapper<Value, Multiple extends boolean | undefined = false>({
isLoading,
placeholder,
emptyMessage,
triggerCls,
contentCls,
groupCls,
itemCls,
indicatorAt = 'right',
showTrigger = true,
showClear = false,
multiple,
disabled,
inputProps,
hideList,
renderValue,
getItemLabel,
renderStatus,
renderEmpty,
items,
...props
}: ComboboxWrapperProps<Value, Multiple>) {
const multiAnchor = React.useRef<HTMLDivElement | null>(null)
const { labelMap, labelStringMap, optionMap } = React.useMemo(() => {
const labelMap: Record<string, React.ReactNode> = {}
const labelStringMap: Record<string, string> = {}
const optionMap: Record<string, allowedPrimitiveT | optionT> = {}
if (!items) return { labelMap, labelStringMap, optionMap }
const process = (opts: optionsT) => {
for (const opt of opts) {
if (isGroup(opt)) {
process(opt.options as optionsT)
} else {
const o = opt as allowedPrimitiveT | optionT
const val = getValue(o)
if (!isSeparator(val)) {
const key = String(val)
const label = getLabel(o)
labelMap[key] = label
labelStringMap[key] = typeof label === 'string' ? label : (extractText(label).trim() || key)
optionMap[key] = o
}
}
}
}
process(items)
return { labelMap, labelStringMap, optionMap }
}, [items])
const itemsForBase = React.useMemo(() => {
if (!items) return []
return items.map(item => isGroup(item) ? { ...item, items: item.options } : item)
}, [items])
const hasPopupInput = !multiple && !!renderValue
return (
<ComboboxRoot
multiple={multiple}
disabled={disabled}
items={itemsForBase as unknown[]}
itemToStringLabel={(item) => {
const key = String(getValue(item as allowedPrimitiveT | optionT))
if (getItemLabel) return getItemLabel(key)
return labelStringMap[key] ?? key
}}
{...props}
>
{multiple ? (
<ComboboxChips ref={multiAnchor} className={cn('w-full', triggerCls)}>
<ComboboxValue>
{(values: allowedPrimitiveT[]) => (
<>
{values?.map(v => (
<ComboboxChip key={String(v)}>
{renderValue
? renderValue(String(v), optionMap[String(v)])
: (labelMap[String(v)] ?? String(v))
}
</ComboboxChip>
))}
<ComboboxChipsInput
placeholder={placeholder}
disabled={disabled}
{...inputProps}
/>
{(showClear || showTrigger) && (
<div className="flex shrink-0 items-center ml-auto">
{showClear && <ComboboxClear disabled={disabled} />}
{showTrigger && <ComboboxTrigger disabled={disabled}><ComboboxIcon /></ComboboxTrigger>}
</div>
)}
</>
)}
</ComboboxValue>
</ComboboxChips>
) : hasPopupInput ? (
<ComboboxTrigger
className={cn(
'flex h-9 w-full items-center gap-1.5 rounded-md border border-input bg-transparent pl-3 pr-1 text-sm shadow-xs',
'transition-colors outline-none select-none',
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:bg-input/30',
)}
render={<div />}
nativeButton={false}
>
<ComboboxValue>
{(v => (
<>
{!v ? <span className=' text-muted-foreground'>{placeholder}</span> : renderValue
? renderValue(String(v), optionMap[String(v)])
: (labelMap[String(v)] ?? String(v))
}
</>
))}
</ComboboxValue>
<div className="flex shrink-0 items-center ml-auto">
{showClear && <ComboboxClear disabled={disabled} />}
{showTrigger && <ComboboxIcon />}
</div>
</ComboboxTrigger>
) : (
<ComboboxInput
disabled={disabled}
showClear={showClear}
showTrigger={showTrigger}
placeholder={placeholder}
className={cn('w-full', triggerCls)}
{...inputProps}
/>
)}
<ComboboxContent anchor={multiple ? multiAnchor : undefined} className={cn(contentCls, hideList && "hidden")}>
{
hasPopupInput &&
<div className="px-2 pt-2">
<ComboboxInput
disabled={disabled}
showTrigger={false}
placeholder={placeholder}
className={cn('w-full focus-within:ring-1', triggerCls)}
{...inputProps}
/>
</div>
}
<ComboboxStatus>
{renderStatus !== undefined
? renderStatus
: isLoading && <p className='flex items-center justify-center gap-2 py-6'><Loader2 className='size-4 animate-spin' /> Loading...</p>}
</ComboboxStatus>
<ComboboxEmpty>
{renderEmpty !== undefined
? renderEmpty
: !isLoading && <p className='py-6'>{emptyMessage ?? 'No options found'}</p>}
</ComboboxEmpty>
<ComboboxList>
{(item: optionT, i: number) => (
<OptionsBody
key={
isGroup(item)
? item.group
: isSeparator(item)
? `sep-${i}`
: String(getValue(item as allowedPrimitiveT | optionT))
}
item={item as optionsT[number]}
index={i}
groupCls={groupCls}
itemCls={itemCls}
indicatorAt={indicatorAt}
/>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
)
}
export {
ComboboxRoot,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxIcon,
ComboboxClear,
ComboboxValue,
ComboboxStatus,
useComboboxAnchor,
ComboboxWrapper,
type ComboboxWrapperProps,
}
Virtualised Installation
ComboboxVirtualisedWrapper renders large flat lists efficiently using @tanstack/react-virtual. Install separately — grouping is not supported.
npx shadcn@latest add @glrk-ui/combobox-virtual'use client'
import * as React from 'react'
import { Loader2 } from 'lucide-react'
import { Combobox as ComboboxPrimitive } from '@base-ui/react'
import { useVirtualizer, type VirtualizerOptions } from '@tanstack/react-virtual'
import { cn, extractText, getLabel, getValue, isGroup, isOption, isSeparator } from '@/lib/utils'
import {
ComboboxRoot,
ComboboxInput,
ComboboxContent,
ComboboxItem,
ComboboxEmpty,
ComboboxStatus,
} from '@/components/ui/combobox'
type ComboboxVirtualListProps = {
itemCls?: string
indicatorAt?: indicatorAtT
estimateSize: number
maxHeight: number
virtualizerOptions?: Partial<Omit<VirtualizerOptions<HTMLDivElement, Element>, 'count' | 'getScrollElement'>>
}
function ComboboxVirtualList({ itemCls, indicatorAt, estimateSize, maxHeight, virtualizerOptions }: ComboboxVirtualListProps) {
const items = (ComboboxPrimitive.useFilteredItems() ?? []) as (allowedPrimitiveT | optionT)[]
const parentRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan: 5,
...virtualizerOptions,
})
return (
<ComboboxPrimitive.List data-slot="combobox-list" className="data-empty:hidden">
<div ref={parentRef} className="overflow-y-auto overscroll-contain p-1" style={{ maxHeight }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => {
const item = items[virtualRow.index]
if (!item) return null
const value = getValue(item)
const label = getLabel(item)
const optCls = isOption(item) ? item.className : undefined
const disabled = isOption(item) ? item.disabled : undefined
return (
<ComboboxItem
key={String(value)}
value={value}
index={virtualRow.index}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
disabled={disabled}
indicatorAt={indicatorAt}
className={cn(itemCls, optCls)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{label}
</ComboboxItem>
)
})}
</div>
</div>
</ComboboxPrimitive.List>
)
}
type ComboboxVirtualisedWrapperProps = Omit<ComboboxPrimitive.Root.Props<unknown, false>, 'items' | 'multiple'> & {
items?: optionsT
isLoading?: boolean
placeholder?: string
emptyMessage?: string
triggerCls?: string
contentCls?: string
itemCls?: string
indicatorAt?: indicatorAtT
showTrigger?: boolean
showClear?: boolean
inputProps?: React.ComponentProps<'input'>
renderStatus?: React.ReactNode
renderEmpty?: React.ReactNode
estimateSize?: number
maxHeight?: number
virtualizerOptions?: Partial<Omit<VirtualizerOptions<HTMLDivElement, Element>, 'count' | 'getScrollElement'>>
}
function ComboboxVirtualisedWrapper({
items,
isLoading,
placeholder,
emptyMessage,
triggerCls,
contentCls,
itemCls,
indicatorAt = 'right',
showTrigger = true,
showClear = false,
disabled,
inputProps,
renderStatus,
renderEmpty,
estimateSize = 32,
maxHeight = 300,
virtualizerOptions,
...props
}: ComboboxVirtualisedWrapperProps) {
const labelStringMap = React.useMemo(() => {
const map: Record<string, string> = {}
if (!items) return map
for (const opt of items) {
if (isGroup(opt)) continue
const o = opt as allowedPrimitiveT | optionT
const val = getValue(o)
if (!isSeparator(val)) {
const key = String(val)
const label = getLabel(o)
map[key] = typeof label === 'string' ? label : (extractText(label).trim() || key)
}
}
return map
}, [items])
const itemsForBase = React.useMemo(() => {
if (!items) return []
return items.filter(item => !isGroup(item) && !isSeparator(getValue(item as allowedPrimitiveT | optionT))) as (allowedPrimitiveT | optionT)[]
}, [items])
return (
<ComboboxRoot
disabled={disabled}
items={itemsForBase as unknown[]}
virtualized
itemToStringLabel={(item) => {
const key = String(getValue(item as allowedPrimitiveT | optionT))
return labelStringMap[key] ?? key
}}
{...props}
>
<ComboboxInput
disabled={disabled}
showClear={showClear}
showTrigger={showTrigger}
placeholder={placeholder}
className={cn('w-full', triggerCls)}
{...inputProps}
/>
<ComboboxContent className={contentCls}>
<ComboboxStatus>
{renderStatus !== undefined
? renderStatus
: isLoading && <p className='flex items-center justify-center gap-2 py-6'><Loader2 className='size-4 animate-spin' /> Loading...</p>}
</ComboboxStatus>
<ComboboxEmpty>
{renderEmpty !== undefined
? renderEmpty
: !isLoading && <p className='py-6'>{emptyMessage ?? 'No options found'}</p>}
</ComboboxEmpty>
<ComboboxVirtualList
itemCls={itemCls}
indicatorAt={indicatorAt}
estimateSize={estimateSize}
maxHeight={maxHeight}
virtualizerOptions={virtualizerOptions}
/>
</ComboboxContent>
</ComboboxRoot>
)
}
export {
ComboboxVirtualisedWrapper,
type ComboboxVirtualisedWrapperProps,
}
Usage
Basic
import { ComboboxWrapper } from "@/components/ui/combobox"
<ComboboxWrapper
items={["Apple", "Banana", "Orange"]}
placeholder="Search fruits..."
/>Multiple selection (chips)
Pass multiple to enable chip-based multi-select:
<ComboboxWrapper
multiple
items={["Apple", "Banana", "Orange"]}
placeholder="Search fruits..."
/>Options format
items accepts primitives, option objects, groups, and separators — mixed freely:
const items = [
"Simple string",
42,
"---", // separator
{ label: "Custom label", value: "custom" },
{ label: <><SunIcon /> Light</>, value: "light" }, // ReactNode label
{ label: "Disabled", value: "off", disabled: true },
{
group: "Category",
options: ["Option A", "Option B"],
},
]
<ComboboxWrapper items={items} placeholder="Pick one" />Grouped options
<ComboboxWrapper
items={[
{ group: "Fruits", options: ["Apple", "Banana"] },
{ group: "Veggies", options: ["Carrot", "Broccoli"] },
]}
placeholder="Select food"
/>Trigger and clear buttons
<ComboboxWrapper items={items} showTrigger showClear placeholder="Search" />
<ComboboxWrapper items={items} showTrigger={false} placeholder="No chevron" />Indicator placement
<ComboboxWrapper items={items} indicatorAt="left" placeholder="Left check" />
<ComboboxWrapper items={items} indicatorAt="right" placeholder="Right check" /> {/* default */}Disabled item
<ComboboxWrapper
items={[
{ label: "Available", value: "a" },
{ label: "Unavailable", value: "b", disabled: true },
]}
placeholder="Select"
/>Loading state
<ComboboxWrapper items={[]} isLoading placeholder="Search..." />Shows "Loading..." in the empty slot while isLoading is true.
Empty message
<ComboboxWrapper items={items} emptyMessage="Nothing matched" placeholder="Search" />Controlled value
Single:
const [value, setValue] = useState<allowedPrimitiveT>("")
<ComboboxWrapper
items={items}
value={value}
onValueChange={setValue}
/>Multiple:
const [values, setValues] = useState<allowedPrimitiveT[]>([])
<ComboboxWrapper
multiple
items={items}
value={values}
onValueChange={setValues}
/>Controlled query and open state
const [query, setQuery] = useState("")
const [open, setOpen] = useState(false)
<ComboboxWrapper
items={items}
query={query}
onQueryChange={setQuery}
open={open}
onOpenChange={setOpen}
/>Async options
Combine controlled query with async fetch:
const [query, setQuery] = useState("")
const { data: items = [], isLoading } = useQuery({
queryKey: ["users", query],
queryFn: () => fetchUsers(query),
})
<ComboboxWrapper
items={items}
isLoading={isLoading}
query={query}
onQueryChange={setQuery}
placeholder="Search users..."
/>Async search
Disable client filtering with filter={() => true} and update items externally on input change:
const [results, setResults] = useState<string[]>([])
const [isFetching, setIsFetching] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleInputChange(query: string) {
if (timerRef.current) clearTimeout(timerRef.current)
if (!query.trim()) { setResults([]); return }
setIsFetching(true)
timerRef.current = setTimeout(async () => {
setResults(await fetchOptions(query))
setIsFetching(false)
}, 400)
}
<ComboboxWrapper
items={results}
filter={() => true}
onInputValueChange={handleInputChange}
placeholder="Type to search…"
showClear
renderStatus={isFetching
? <p className="flex items-center gap-2 p-2 text-sm"><Loader2 className="size-4 animate-spin" /> Fetching...</p>
: null
}
renderEmpty={<p className="py-6 text-center text-sm">No results found</p>}
/>Custom status and empty slots
Override the default loading/empty UI with renderStatus and renderEmpty:
<ComboboxWrapper
items={items}
isLoading={isLoading}
renderStatus={isLoading ? <MySpinner /> : null}
renderEmpty={<p className="py-6 text-center">Nothing here yet</p>}
/>Custom chip label (renderValue)
Used in multiple mode — controls what renders inside each chip. Receives the raw value string and the matched option:
<ComboboxWrapper
multiple
items={[
{ label: <><SunIcon /> Light</>, value: "light" },
{ label: <><MoonIcon /> Dark</>, value: "dark" },
]}
renderValue={(value, option) => (
<span className="flex items-center gap-1">
{value === "light" ? <SunIcon className="size-3" /> : <MoonIcon className="size-3" />}
{value}
</span>
)}
/>Custom input label (getItemLabel)
Controls the string shown in the input after selection (single mode). Useful when option labels are ReactNodes:
<ComboboxWrapper
items={[
{ label: <><SunIcon /> Light</>, value: "light" },
{ label: <><MoonIcon /> Dark</>, value: "dark" },
]}
getItemLabel={(value) => value === "light" ? "Light mode" : "Dark mode"}
/>Without
getItemLabel, the wrapper usesextractTextto derive a string from ReactNode labels automatically.
Create new option
Use hideList to hide the dropdown list (render your own "Create" item), and wire up onValueChange to extend items:
const [items, setItems] = useState(["Apple", "Banana"])
const [query, setQuery] = useState("")
const hasMatch = items.some(
(i) => String(i).toLowerCase() === query.toLowerCase()
)
<ComboboxWrapper
items={items}
query={query}
onQueryChange={setQuery}
onValueChange={(v) => {
if (!items.includes(v as string)) setItems((prev) => [...prev, v as string])
}}
hideList={hasMatch || !query}
placeholder="Type to create..."
/>Additional input props
Pass native input attributes via inputProps:
<ComboboxWrapper
items={items}
inputProps={{ autoComplete: "off", maxLength: 50 }}
/>Custom styling
<ComboboxWrapper
items={items}
placeholder="Styled"
triggerCls="w-72"
contentCls="max-h-48"
groupCls="pb-0"
itemCls="text-xs"
/>Virtualised — large lists
Use ComboboxVirtualisedWrapper for lists with hundreds or thousands of items:
import { ComboboxVirtualisedWrapper } from "@/components/ui/combobox-virtualised"
<ComboboxVirtualisedWrapper
items={largeList}
placeholder="Search 1000 options…"
triggerCls="w-64"
showClear
/>Custom row height and overscan:
<ComboboxVirtualisedWrapper
items={largeList}
placeholder="Search…"
maxHeight={200}
estimateSize={36}
virtualizerOptions={{ overscan: 10 }}
/>Reference
Prop
Type
Virtualised Reference
ComboboxVirtualisedWrapper accepts all the same props as ComboboxWrapper except multiple, hideList, groupCls, and renderValue. The following props are unique to the virtualised variant:
Prop
Type