Glrk UI

Combobox

A simplified wrapper for Shadcn Combobox with flexible option handling and automatic type conversion

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 readOnlyChildren = Readonly<{
  children: React.ReactNode;
}>

type allowedPrimitiveT = string | number | boolean

type optionT = {
  label: React.ReactNode
  value: allowedPrimitiveT
  className?: string
}

type groupT = {
  group: string
  options: (allowedPrimitiveT | optionT)[]
  className?: string
}

type optionsT = (allowedPrimitiveT | optionT | groupT)[]

type indicatorAtT = "right" | "left"
lib/utils.ts
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}`
}
ui/combobox.tsx
"use client"

import * as React from "react"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"

import { cn, 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 ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
  return (
    <ComboboxPrimitive.Trigger
      data-slot="combobox-trigger"
      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}
    >
      {children}
      <ChevronDownIcon className="size-4" />
    </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(
        "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="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} />}
      </div>
      {children}
    </ComboboxPrimitive.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}
      >
        <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-[min(18rem,calc(var(--available-height)-2.5rem))] overflow-y-auto overscroll-contain scroll-py-1 p-1",
        "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
        "data-empty:hidden",
        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 select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 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,
      )}
      {...props}
    >
      {children}
      <ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center text-foreground">
        <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(
        "hidden py-6 text-center text-sm text-muted-foreground",
        "group-data-empty/combobox-content:flex group-data-empty/combobox-content:w-full group-data-empty/combobox-content:justify-center",
        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 useComboboxAnchor() {
  return React.useRef<HTMLDivElement | null>(null)
}

type OptionItemProps = {
  option: allowedPrimitiveT | optionT
  className?: string
}

function OptionItem({ option, className }: OptionItemProps) {
  const value = getValue(option)
  const label = getLabel(option)
  const optCls = isOption(option) ? option.className : undefined

  return (
    <ComboboxItem value={value} className={cn(className, optCls)}>
      {label}
    </ComboboxItem>
  )
}

type OptionsBodyProps = {
  item: optionsT[number]
  index: number
  itemCls?: string
  groupCls?: string
}

function OptionsBody({ item, index, itemCls, groupCls }: 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) => (
            <OptionItem key={getKey(opt, 0)} option={opt} className={cn(itemCls)} />
          )}
        </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)}
    />
  )
}

type ComboboxWrapperProps<
  Value = unknown,
  Multiple extends boolean | undefined = boolean | undefined
> = ComboboxPrimitive.Root.Props<Value, Multiple> & {
  isLoading?: boolean
  placeholder?: string
  emptyMessage?: string
  triggerCls?: string
  contentCls?: string
  groupCls?: string
  itemCls?: string
}

function ComboboxWrapper<Value, Multiple extends boolean | undefined = false>({
  isLoading,
  placeholder,
  emptyMessage,
  triggerCls,
  contentCls,
  groupCls,
  itemCls,
  multiple,
  disabled,
  ...props
}: ComboboxWrapperProps<Value, Multiple>) {
  const multiAnchor = React.useRef<HTMLDivElement | null>(null)

  return (
    <ComboboxRoot multiple={multiple} disabled={disabled} {...props}>
      {multiple ? (
        <ComboboxChips ref={multiAnchor} className={cn("w-full", triggerCls)}>
          <ComboboxValue>
            {(values: allowedPrimitiveT[]) => (
              <>
                {values?.map((v) => (
                  <ComboboxChip key={String(v)}>
                    {String(v)}
                  </ComboboxChip>
                ))}

                <ComboboxChipsInput
                  placeholder={placeholder}
                  disabled={disabled}
                />

                <div className="flex shrink-0 items-center ml-auto">
                  <ComboboxClear disabled={disabled} />
                  <ComboboxTrigger disabled={disabled} />
                </div>
              </>
            )}
          </ComboboxValue>
        </ComboboxChips>
      ) : (
        <ComboboxInput
          placeholder={placeholder}
          disabled={disabled}
          showTrigger
          showClear
          className={cn("w-full", triggerCls)}
        />
      )}

      <ComboboxContent anchor={multiple ? multiAnchor : undefined} className={contentCls}>
        <ComboboxEmpty>
          {isLoading ? "Loading..." : (emptyMessage ?? "No options found")}
        </ComboboxEmpty>

        <ComboboxList>
          {(item: optionsT[number], i: number) => (
            <OptionsBody
              key={
                isGroup(item)
                  ? item.group
                  : isSeparator(item)
                    ? `sep-${i}`
                    : String(getValue(item as allowedPrimitiveT | optionT))
              }
              item={item}
              index={i}
              groupCls={groupCls}
              itemCls={itemCls}
            />
          )}
        </ComboboxList>
      </ComboboxContent>
    </ComboboxRoot>
  )
}

export {
  ComboboxRoot,
  ComboboxInput,
  ComboboxContent,
  ComboboxList,
  ComboboxItem,
  ComboboxGroup,
  ComboboxLabel,
  ComboboxCollection,
  ComboboxEmpty,
  ComboboxSeparator,
  ComboboxChips,
  ComboboxChip,
  ComboboxChipsInput,
  ComboboxTrigger,
  ComboboxClear,
  ComboboxValue,
  useComboboxAnchor,
  ComboboxWrapper,
  type ComboboxWrapperProps,
}

Usage

Basic

import { ComboboxWrapper } from "@/components/ui/combobox"

export function Basic() {
  return (
    <>
      <ComboboxWrapper
        options={["Option 1", "Option 2", "Option 3"]}
        placeholder="Select an option"
      />
      
      <ComboboxWrapper
        multiple
        options={["Option 1", "Option 2", "Option 3"]}
        placeholder="Select an option"
      />
    </>
  )
}

Controlled

import { useState } from "react"
import { ComboboxWrapper } from "@/components/ui/combobox"

export function Controlled() {
  const [values, setValues] = useState<allowedPrimitiveT[]>([])
  const [value, setValue] = useState<allowedPrimitiveT>("")

  return (
    <>
      <ComboboxWrapper
        options={["Option 1", "Option 2", "Option 3"]}
        placeholder="Select an option"
        value={value}
        onValueChange={setValue}
      />
      
      <ComboboxWrapper
        multiple
        options={["Option 1", "Option 2", "Option 3"]}
        placeholder="Select an option"
        value={values}
        onValueChange={setValues}
      />
    </>
  )
}

export function Async() {
  const { data: options, isLoading } = useAsyncOptions()
  
  return (
    <ComboboxWrapper
      options={options || []}
      isLoading={isLoading}
    />
  )
}

Custom Styling

export function Styled() {
  return (
    <ComboboxWrapper
      options={["Small", "Medium", "Large"]}
      placeholder="Select size"
      triggerCls="w-[200px] bg-slate-50"
      contentCls="min-w-[200px]"
      itemCls="cursor-pointer hover:bg-slate-100"
      groupCls="py-2"
    />
  )
}

Reference

Common Properties

Prop

Type

Combobox

Prop

Type