Glrk UI

Combobox

A wrapper for Base UI Combobox with single and multi-select, chips, grouped options, async loading, custom label rendering, and create-new support.

Basic
Flat list
Grouped options
With separator
Controls
Trigger only (default)
With clear button
No trigger — search only
Indicator
Right (default)
Left
State
Default value — Mango pre-selected
Disabled root — non-interactive
Disabled item — Viewer unreachable
Custom empty message
Multiple
Multiple — chips per selection
Multiple + clear all
Controlled
Controlled value via useState
Value:
Async
Async load — 1.5 s simulated delay
Async search — filter on typing, 400 ms debounce
Create
canCreate — Enter creates single value
canCreate — Enter appends chip (multiple)
Render Value
Priority chips — icon + coloured label per priority
Team chips — avatar initials + first name
Virtualised
1000 items — virtualised list
1000 items — custom height + overscan

Installation

npx shadcn@latest add @glrk-ui/combobox

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

types/general.d.ts
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'
lib/utils.ts
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 ''
}
ui/combobox.tsx
'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
ui/combobox-virtualised.tsx
'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..."
/>

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 uses extractText to 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