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.

The Combobox is built using a composition of the <Popover />, <Badge />, <Button /> and the <Command /> components.

See installation instructions for the components from shadcn.

ui/combobox.tsx
"use client"

import { useState } from "react"
import { Check, ChevronsUpDown, Loader2, Plus } from "lucide-react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn, getKey, getLabel, getValue, isAllowedPrimitive, isGroup, isOption, isSeparator } from "@/lib/utils"

import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandLoading,
  CommandSeparator,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"

const extractText = (node: any): string => {
  if (node === null || node === undefined) return ""
  if (isAllowedPrimitive(node)) return String(node)
  if (Array.isArray(node)) return node.map(extractText).join(" ")
  if (node.props?.children) return extractText(node.props.children)
  return ""
}

const findOptionByValue = (options: optionsT, value: allowedPrimitiveT) => {
  for (const item of options) {
    if (isGroup(item)) {
      const found = item.options.find((opt) => getValue(opt) === value)
      if (found) return found
    } else if (!isSeparator(item) && getValue(item) === value) {
      return item
    }
  }
  return ""
}

const filteredOptions = (options: optionsT, query: string): optionsT => {
  const q = query.toLowerCase()
  const result: optionsT = []

  for (const item of options) {
    if (isGroup(item)) {
      const filtered = item.options.filter(opt =>
        extractText(getLabel(opt)).toLowerCase().includes(q)
      )
      if (filtered.length) {
        result.push({ ...item, options: filtered })
      }
      continue
    }

    if (isSeparator(item)) {
      result.push(item)
      continue
    }

    if (extractText(getLabel(item)).toLowerCase().includes(q)) {
      result.push(item)
    }
  }

  return result
}


type ItemProps = {
  option: allowedPrimitiveT | optionT
  selected: boolean
  className?: string
  indicatorAt?: indicatorAtT
  onSelect: (value: allowedPrimitiveT) => void
}

function Item({ option, selected, indicatorAt = "right", onSelect, className }: ItemProps) {
  const value = getValue(option)
  const label = getLabel(option)
  const optCls = isOption(option) ? option.className : undefined

  if (isSeparator(value)) return <CommandSeparator className={cn("my-0.5", className, "mx-0")} />

  return (
    <CommandItem
      value={`${value}`}
      className={cn(indicatorAt === "right" ? "pr-8 pl-2" : "pr-2 pl-8", className, optCls)}
      onSelect={() => onSelect(value)}
    >
      {label}

      <Check
        className={cn(
          "absolute size-4",
          selected ? "opacity-100" : "opacity-0",
          indicatorAt === "right" ? "right-2" : "left-2",
        )}
      />
    </CommandItem>
  )
}

type base = {
  id?: string
  options: optionsT
  isLoading?: boolean
  placeholder?: string
  emptyMessage?: string

  indicatorAt?: indicatorAtT
  triggerCls?: string
  contentCls?: string
  groupCls?: string
  itemCls?: string
  matchTriggerWidth?: boolean

  open?: boolean
  onOpenChange?: (v: boolean) => void
  query?: string
  onQueryChange?: (v: string) => void

  popoverContentProps?: Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, "className">
}

type comboboxProps = base & {
  value?: allowedPrimitiveT
  canCreateNew?: boolean
  onValueChange?: (value: allowedPrimitiveT) => void
}

function Combobox({
  id,
  options = [],
  isLoading,
  placeholder,
  emptyMessage,
  canCreateNew,

  matchTriggerWidth = true,
  indicatorAt,
  triggerCls,
  contentCls,
  groupCls,
  itemCls,

  value: o_value,
  onValueChange: o_onValueChange,

  query: o_query,
  onQueryChange: o_onQueryChange,

  open: o_open,
  onOpenChange: o_onOpenChange,

  popoverContentProps,
}: comboboxProps) {
  const [i_value, setIValue] = useState("")
  const [i_query, setIQuery] = useState("")
  const [i_open, setIOpen] = useState(false)

  const value = o_value ?? i_value
  const query = o_query ?? i_query
  const open = o_open ?? i_open

  const onValueChange = o_onValueChange ?? setIValue
  const onQueryChange = o_onQueryChange ?? setIQuery
  const onOpenChange = o_onOpenChange ?? setIOpen

  const selectedOption = findOptionByValue(options, value)
  const filtered = filteredOptions(options, query)
  const label = getLabel(selectedOption)

  const showCreate =
    canCreateNew &&
    query &&
    !options.some((o) =>
      isGroup(o)
        ? o.options.some((x) => extractText(getLabel(x)) === query)
        : extractText(getLabel(o)) === query
    )

  function onSelect(v: allowedPrimitiveT) {
    onValueChange(v as string)
    onOpenChange(false)
  }

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <Button
          id={id}
          role="combobox"
          variant="outline"
          className={cn("font-normal", triggerCls, {
            "text-muted-foreground": !value && value !== false,
          })}
        >
          {isLoading ? (
            <>
              <Loader2 className="size-4 animate-spin" />
              Loading...
            </>
          ) :
            <span className="flex items-center gap-2 truncate">
              {
                (selectedOption || selectedOption === false)
                  ? label
                  : placeholder
              }
            </span>
          }

          <ChevronsUpDown className="ml-auto size-4 opacity-50" />
        </Button>
      </PopoverTrigger>

      <PopoverContent
        {...popoverContentProps}
        className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
      >
        <Command shouldFilter={false}>
          {
            !isLoading &&
            <CommandInput
              placeholder="Search..."
              value={query}
              onValueChange={onQueryChange}
            />
          }

          <CommandList className="py-1">
            {
              isLoading &&
              <CommandLoading>
                <span className="flex items-center justify-center gap-2">
                  <Loader2 className="animate-spin size-4" /> Loading...
                </span>
              </CommandLoading>
            }

            {
              !isLoading &&
              <CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
            }

            {!isLoading && filtered.map((item, i) => {
              if (isGroup(item)) {
                return (
                  <CommandGroup
                    key={item.group}
                    heading={item.group}
                    className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
                  >
                    {item.options.map((opt, j) => (
                      <Item
                        key={getKey(opt, j)}
                        option={opt}
                        selected={getValue(opt) === value}
                        onSelect={onSelect}
                        className={itemCls}
                        indicatorAt={indicatorAt}
                      />
                    ))}
                  </CommandGroup>
                )
              }

              return (
                <Item
                  key={getKey(item, i)}
                  option={item}
                  selected={getValue(item) === value}
                  onSelect={onSelect}
                  indicatorAt={indicatorAt}
                  className={cn("mx-1", itemCls)}
                />
              )
            })}

            {showCreate && (
              <CommandGroup>
                <CommandItem
                  value={`__create-${query}`}
                  onSelect={() => {
                    onValueChange(query)
                    onQueryChange("")
                    onOpenChange(false)
                  }}
                >
                  <Plus className="mr-2 h-4 w-4" /> Create: {query}
                </CommandItem>
              </CommandGroup>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

type btnLableProps = {
  value: allowedPrimitiveT[]
  options: optionsT
  isLoading?: boolean
  placeholder?: string
  maxVisibleCount?: number
}
function ButtonLabel({
  value,
  options,
  isLoading,
  placeholder,
  maxVisibleCount = 2,
}: btnLableProps) {
  const labelOf = (val: allowedPrimitiveT) => {
    const found = findOptionByValue(options, val)
    if (!found) return `${val}`
    const label = getLabel(found)
    return label
  }

  if (isLoading)
    return (
      <>
        <Loader2 className="size-4 animate-spin" />
        Loading...
      </>
    )

  if (value.length === 0) {
    return placeholder
  }

  if (value.length <= maxVisibleCount) {
    return (
      <>
        {value.map((v) => (
          <Badge key={String(v)} variant="secondary" className="rounded-sm px-1 font-normal">
            {labelOf(v)}
          </Badge>
        ))}
      </>
    )
  }

  return (
    <Badge variant="secondary" className="rounded-sm px-1 font-normal">
      {value.length} selected
    </Badge>
  )
}

type multiSelectComboboxProps = base & {
  value?: allowedPrimitiveT[]
  maxVisibleCount?: number
  label?: React.ReactNode
  onValueChange?: (v: allowedPrimitiveT[]) => void
}

function MultiSelectCombobox({
  id,
  options = [],
  isLoading,
  placeholder,
  emptyMessage,

  matchTriggerWidth = true,
  maxVisibleCount,
  indicatorAt,
  triggerCls,
  contentCls,
  groupCls,
  itemCls,
  label,

  value: o_value,
  onValueChange: o_onValueChange,

  query: o_query,
  onQueryChange: o_onQueryChange,

  open: o_open,
  onOpenChange: o_onOpenChange,
  popoverContentProps,
}: multiSelectComboboxProps) {
  const [i_value, setIValue] = useState<allowedPrimitiveT[]>([])
  const [i_query, setIQuery] = useState("")
  const [i_open, setIOpen] = useState(false)

  const value = o_value ?? i_value
  const query = o_query ?? i_query
  const open = o_open ?? i_open

  const onValueChange = o_onValueChange ?? setIValue
  const onQueryChange = o_onQueryChange ?? setIQuery
  const onOpenChange = o_onOpenChange ?? setIOpen

  const filtered = filteredOptions(options, query)

  const onSelect = (v: allowedPrimitiveT) => {
    onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v])
  }

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <Button
          id={id}
          role="combobox"
          variant="outline"
          aria-expanded={open}
          className={cn("font-normal", triggerCls, {
            "text-muted-foreground": value.length === 0,
          })}
        >
          {label}

          <ButtonLabel
            value={value}
            options={options}
            isLoading={isLoading}
            placeholder={placeholder}
            maxVisibleCount={maxVisibleCount}
          />

          <ChevronsUpDown className="ml-auto size-4 opacity-50" />
        </Button>
      </PopoverTrigger>

      <PopoverContent
        {...popoverContentProps}
        className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
      >
        <Command shouldFilter={false}>
          {
            !isLoading &&
            <CommandInput
              placeholder="Search..."
              value={query}
              onValueChange={onQueryChange}
            />
          }

          <CommandList className="py-1">
            {
              isLoading &&
              <CommandLoading>
                <span className="flex items-center justify-center gap-2">
                  <Loader2 className="animate-spin size-4" /> Loading...
                </span>
              </CommandLoading>
            }

            {
              !isLoading &&
              <CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
            }

            {!isLoading && filtered.map((item, i) => {
              if (isGroup(item)) {
                return (
                  <CommandGroup
                    key={item.group}
                    heading={item.group}
                    className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
                  >
                    {item.options.map((opt, j) => (
                      <Item
                        key={getKey(opt, j)}
                        option={opt}
                        selected={value.includes(getValue(opt))}
                        onSelect={onSelect}
                        indicatorAt={indicatorAt}
                        className={itemCls}
                      />
                    ))}
                  </CommandGroup>
                )
              }

              return (
                <Item
                  key={getKey(item, i)}
                  option={item}
                  selected={value.includes(getValue(item))}
                  onSelect={onSelect}
                  indicatorAt={indicatorAt}
                  className={cn("mx-1 mb-1", itemCls)}
                />
              )
            })}

            {value.length > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem onSelect={() => onValueChange([])} value="__clear__" className="justify-center">
                    Clear selection(s)
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

export {
  Combobox,
  MultiSelectCombobox,
  type comboboxProps,
  type multiSelectComboboxProps,
}

Copy and paste the following code shadcn command component.

function CommandLoading({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Loading>) {
  return (
    <CommandPrimitive.Loading
      data-slot="command-loading"
      className={cn(
        "flex items-center justify-center min-h-20 text-sm",
        className
      )}
      {...props}
    />
  )
}

Add CommandLoading at the export block.

export {
  Command,
  CommandDialog,
  CommandInput,
  CommandList,
  CommandEmpty,
  CommandGroup,
  CommandItem,
  CommandShortcut,
  CommandSeparator,
  CommandLoading,
}

Installation

npm install @radix-ui/react-slot @radix-ui/react-popover cmdk

Copy and paste the following code into your project.

general.d.ts

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"

utils.ts

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}`
}

badge.tsx

ui/badge.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
        secondary:
          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
        destructive:
          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function Badge({
  className,
  variant,
  asChild = false,
  ...props
}: React.ComponentProps<"span"> &
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot : "span"

  return (
    <Comp
      data-slot="badge"
      className={cn(badgeVariants({ variant }), className)}
      {...props}
    />
  )
}

export { Badge, badgeVariants }

button.tsx

ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
        "icon-sm": "size-8",
        "icon-lg": "size-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot : "button"

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }

popover.tsx

ui/popover.tsx
"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
  return <PopoverPrimitive.Root data-slot="popover" {...props} />
}

function PopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

function PopoverContent({
  className,
  align = "center",
  sideOffset = 4,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Content
        data-slot="popover-content"
        align={align}
        sideOffset={sideOffset}
        className={cn(
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
          className
        )}
        {...props}
      />
    </PopoverPrimitive.Portal>
  )
}

function PopoverAnchor(props: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}

type PopoverWrapperProps = {
  trigger: React.ReactNode
  content: React.ReactNode
  triggerCls?: string
  contentCls?: string
  contentProps?: Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, "className">
} & Omit<React.ComponentProps<typeof PopoverPrimitive.Root>, "children">

function PopoverWrapper({
  trigger,
  content,
  triggerCls,
  contentCls,
  contentProps,
  ...props
}: PopoverWrapperProps) {
  return (
    <Popover {...props}>
      <PopoverTrigger
        className={triggerCls}
        asChild={typeof trigger !== "string"}
      >
        {trigger}
      </PopoverTrigger>

      <PopoverContent {...contentProps} className={contentCls}>
        {content}
      </PopoverContent>
    </Popover>
  )
}

export {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverAnchor,
  PopoverWrapper,
}

command.tsx

ui/command.tsx
"use client"

import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"

function Command({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive>) {
  return (
    <CommandPrimitive
      data-slot="command"
      className={cn(
        "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
        className
      )}
      {...props}
    />
  )
}

function CommandDialog({
  title = "Command Palette",
  description = "Search for a command to run...",
  children,
  className,
  showCloseButton = true,
  ...props
}: React.ComponentProps<typeof Dialog> & {
  title?: string
  description?: string
  className?: string
  showCloseButton?: boolean
}) {
  return (
    <Dialog {...props}>
      <DialogHeader className="sr-only">
        <DialogTitle>{title}</DialogTitle>
        <DialogDescription>{description}</DialogDescription>
      </DialogHeader>
      <DialogContent
        className={cn("overflow-hidden p-0", className)}
        showCloseButton={showCloseButton}
      >
        <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
          {children}
        </Command>
      </DialogContent>
    </Dialog>
  )
}

function CommandInput({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
  return (
    <div
      data-slot="command-input-wrapper"
      className="flex h-9 items-center gap-2 border-b px-3"
    >
      <SearchIcon className="size-4 shrink-0 opacity-50" />
      <CommandPrimitive.Input
        data-slot="command-input"
        className={cn(
          "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        {...props}
      />
    </div>
  )
}

function CommandList({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
  return (
    <CommandPrimitive.List
      data-slot="command-list"
      className={cn(
        "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
        className
      )}
      {...props}
    />
  )
}

function CommandEmpty({
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
  return (
    <CommandPrimitive.Empty
      data-slot="command-empty"
      className="py-6 text-center text-sm"
      {...props}
    />
  )
}

function CommandGroup({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
  return (
    <CommandPrimitive.Group
      data-slot="command-group"
      className={cn(
        "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
        className
      )}
      {...props}
    />
  )
}

function CommandSeparator({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
  return (
    <CommandPrimitive.Separator
      data-slot="command-separator"
      className={cn("bg-border -mx-1 h-px", className)}
      {...props}
    />
  )
}

function CommandItem({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
  return (
    <CommandPrimitive.Item
      data-slot="command-item"
      className={cn(
        "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function CommandShortcut({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="command-shortcut"
      className={cn(
        "text-muted-foreground ml-auto text-xs tracking-widest",
        className
      )}
      {...props}
    />
  )
}

function CommandLoading({
  className,
  ...props
}: React.ComponentProps<typeof CommandPrimitive.Loading>) {
  return (
    <CommandPrimitive.Loading
      data-slot="command-loading"
      className={cn(
        "flex items-center justify-center min-h-20 text-sm",
        className
      )}
      {...props}
    />
  )
}

export {
  Command,
  CommandDialog,
  CommandInput,
  CommandList,
  CommandEmpty,
  CommandGroup,
  CommandItem,
  CommandShortcut,
  CommandSeparator,
  CommandLoading,
}

combobox.tsx

ui/combobox.tsx
"use client"

import { useState } from "react"
import { Check, ChevronsUpDown, Loader2, Plus } from "lucide-react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn, getKey, getLabel, getValue, isAllowedPrimitive, isGroup, isOption, isSeparator } from "@/lib/utils"

import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandLoading,
  CommandSeparator,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"

const extractText = (node: any): string => {
  if (node === null || node === undefined) return ""
  if (isAllowedPrimitive(node)) return String(node)
  if (Array.isArray(node)) return node.map(extractText).join(" ")
  if (node.props?.children) return extractText(node.props.children)
  return ""
}

const findOptionByValue = (options: optionsT, value: allowedPrimitiveT) => {
  for (const item of options) {
    if (isGroup(item)) {
      const found = item.options.find((opt) => getValue(opt) === value)
      if (found) return found
    } else if (!isSeparator(item) && getValue(item) === value) {
      return item
    }
  }
  return ""
}

const filteredOptions = (options: optionsT, query: string): optionsT => {
  const q = query.toLowerCase()
  const result: optionsT = []

  for (const item of options) {
    if (isGroup(item)) {
      const filtered = item.options.filter(opt =>
        extractText(getLabel(opt)).toLowerCase().includes(q)
      )
      if (filtered.length) {
        result.push({ ...item, options: filtered })
      }
      continue
    }

    if (isSeparator(item)) {
      result.push(item)
      continue
    }

    if (extractText(getLabel(item)).toLowerCase().includes(q)) {
      result.push(item)
    }
  }

  return result
}


type ItemProps = {
  option: allowedPrimitiveT | optionT
  selected: boolean
  className?: string
  indicatorAt?: indicatorAtT
  onSelect: (value: allowedPrimitiveT) => void
}

function Item({ option, selected, indicatorAt = "right", onSelect, className }: ItemProps) {
  const value = getValue(option)
  const label = getLabel(option)
  const optCls = isOption(option) ? option.className : undefined

  if (isSeparator(value)) return <CommandSeparator className={cn("my-0.5", className, "mx-0")} />

  return (
    <CommandItem
      value={`${value}`}
      className={cn(indicatorAt === "right" ? "pr-8 pl-2" : "pr-2 pl-8", className, optCls)}
      onSelect={() => onSelect(value)}
    >
      {label}

      <Check
        className={cn(
          "absolute size-4",
          selected ? "opacity-100" : "opacity-0",
          indicatorAt === "right" ? "right-2" : "left-2",
        )}
      />
    </CommandItem>
  )
}

type base = {
  id?: string
  options: optionsT
  isLoading?: boolean
  placeholder?: string
  emptyMessage?: string

  indicatorAt?: indicatorAtT
  triggerCls?: string
  contentCls?: string
  groupCls?: string
  itemCls?: string
  matchTriggerWidth?: boolean

  open?: boolean
  onOpenChange?: (v: boolean) => void
  query?: string
  onQueryChange?: (v: string) => void

  popoverContentProps?: Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, "className">
}

type comboboxProps = base & {
  value?: allowedPrimitiveT
  canCreateNew?: boolean
  onValueChange?: (value: allowedPrimitiveT) => void
}

function Combobox({
  id,
  options = [],
  isLoading,
  placeholder,
  emptyMessage,
  canCreateNew,

  matchTriggerWidth = true,
  indicatorAt,
  triggerCls,
  contentCls,
  groupCls,
  itemCls,

  value: o_value,
  onValueChange: o_onValueChange,

  query: o_query,
  onQueryChange: o_onQueryChange,

  open: o_open,
  onOpenChange: o_onOpenChange,

  popoverContentProps,
}: comboboxProps) {
  const [i_value, setIValue] = useState("")
  const [i_query, setIQuery] = useState("")
  const [i_open, setIOpen] = useState(false)

  const value = o_value ?? i_value
  const query = o_query ?? i_query
  const open = o_open ?? i_open

  const onValueChange = o_onValueChange ?? setIValue
  const onQueryChange = o_onQueryChange ?? setIQuery
  const onOpenChange = o_onOpenChange ?? setIOpen

  const selectedOption = findOptionByValue(options, value)
  const filtered = filteredOptions(options, query)
  const label = getLabel(selectedOption)

  const showCreate =
    canCreateNew &&
    query &&
    !options.some((o) =>
      isGroup(o)
        ? o.options.some((x) => extractText(getLabel(x)) === query)
        : extractText(getLabel(o)) === query
    )

  function onSelect(v: allowedPrimitiveT) {
    onValueChange(v as string)
    onOpenChange(false)
  }

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <Button
          id={id}
          role="combobox"
          variant="outline"
          className={cn("font-normal", triggerCls, {
            "text-muted-foreground": !value && value !== false,
          })}
        >
          {isLoading ? (
            <>
              <Loader2 className="size-4 animate-spin" />
              Loading...
            </>
          ) :
            <span className="flex items-center gap-2 truncate">
              {
                (selectedOption || selectedOption === false)
                  ? label
                  : placeholder
              }
            </span>
          }

          <ChevronsUpDown className="ml-auto size-4 opacity-50" />
        </Button>
      </PopoverTrigger>

      <PopoverContent
        {...popoverContentProps}
        className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
      >
        <Command shouldFilter={false}>
          {
            !isLoading &&
            <CommandInput
              placeholder="Search..."
              value={query}
              onValueChange={onQueryChange}
            />
          }

          <CommandList className="py-1">
            {
              isLoading &&
              <CommandLoading>
                <span className="flex items-center justify-center gap-2">
                  <Loader2 className="animate-spin size-4" /> Loading...
                </span>
              </CommandLoading>
            }

            {
              !isLoading &&
              <CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
            }

            {!isLoading && filtered.map((item, i) => {
              if (isGroup(item)) {
                return (
                  <CommandGroup
                    key={item.group}
                    heading={item.group}
                    className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
                  >
                    {item.options.map((opt, j) => (
                      <Item
                        key={getKey(opt, j)}
                        option={opt}
                        selected={getValue(opt) === value}
                        onSelect={onSelect}
                        className={itemCls}
                        indicatorAt={indicatorAt}
                      />
                    ))}
                  </CommandGroup>
                )
              }

              return (
                <Item
                  key={getKey(item, i)}
                  option={item}
                  selected={getValue(item) === value}
                  onSelect={onSelect}
                  indicatorAt={indicatorAt}
                  className={cn("mx-1", itemCls)}
                />
              )
            })}

            {showCreate && (
              <CommandGroup>
                <CommandItem
                  value={`__create-${query}`}
                  onSelect={() => {
                    onValueChange(query)
                    onQueryChange("")
                    onOpenChange(false)
                  }}
                >
                  <Plus className="mr-2 h-4 w-4" /> Create: {query}
                </CommandItem>
              </CommandGroup>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

type btnLableProps = {
  value: allowedPrimitiveT[]
  options: optionsT
  isLoading?: boolean
  placeholder?: string
  maxVisibleCount?: number
}
function ButtonLabel({
  value,
  options,
  isLoading,
  placeholder,
  maxVisibleCount = 2,
}: btnLableProps) {
  const labelOf = (val: allowedPrimitiveT) => {
    const found = findOptionByValue(options, val)
    if (!found) return `${val}`
    const label = getLabel(found)
    return label
  }

  if (isLoading)
    return (
      <>
        <Loader2 className="size-4 animate-spin" />
        Loading...
      </>
    )

  if (value.length === 0) {
    return placeholder
  }

  if (value.length <= maxVisibleCount) {
    return (
      <>
        {value.map((v) => (
          <Badge key={String(v)} variant="secondary" className="rounded-sm px-1 font-normal">
            {labelOf(v)}
          </Badge>
        ))}
      </>
    )
  }

  return (
    <Badge variant="secondary" className="rounded-sm px-1 font-normal">
      {value.length} selected
    </Badge>
  )
}

type multiSelectComboboxProps = base & {
  value?: allowedPrimitiveT[]
  maxVisibleCount?: number
  label?: React.ReactNode
  onValueChange?: (v: allowedPrimitiveT[]) => void
}

function MultiSelectCombobox({
  id,
  options = [],
  isLoading,
  placeholder,
  emptyMessage,

  matchTriggerWidth = true,
  maxVisibleCount,
  indicatorAt,
  triggerCls,
  contentCls,
  groupCls,
  itemCls,
  label,

  value: o_value,
  onValueChange: o_onValueChange,

  query: o_query,
  onQueryChange: o_onQueryChange,

  open: o_open,
  onOpenChange: o_onOpenChange,
  popoverContentProps,
}: multiSelectComboboxProps) {
  const [i_value, setIValue] = useState<allowedPrimitiveT[]>([])
  const [i_query, setIQuery] = useState("")
  const [i_open, setIOpen] = useState(false)

  const value = o_value ?? i_value
  const query = o_query ?? i_query
  const open = o_open ?? i_open

  const onValueChange = o_onValueChange ?? setIValue
  const onQueryChange = o_onQueryChange ?? setIQuery
  const onOpenChange = o_onOpenChange ?? setIOpen

  const filtered = filteredOptions(options, query)

  const onSelect = (v: allowedPrimitiveT) => {
    onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v])
  }

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <Button
          id={id}
          role="combobox"
          variant="outline"
          aria-expanded={open}
          className={cn("font-normal", triggerCls, {
            "text-muted-foreground": value.length === 0,
          })}
        >
          {label}

          <ButtonLabel
            value={value}
            options={options}
            isLoading={isLoading}
            placeholder={placeholder}
            maxVisibleCount={maxVisibleCount}
          />

          <ChevronsUpDown className="ml-auto size-4 opacity-50" />
        </Button>
      </PopoverTrigger>

      <PopoverContent
        {...popoverContentProps}
        className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
      >
        <Command shouldFilter={false}>
          {
            !isLoading &&
            <CommandInput
              placeholder="Search..."
              value={query}
              onValueChange={onQueryChange}
            />
          }

          <CommandList className="py-1">
            {
              isLoading &&
              <CommandLoading>
                <span className="flex items-center justify-center gap-2">
                  <Loader2 className="animate-spin size-4" /> Loading...
                </span>
              </CommandLoading>
            }

            {
              !isLoading &&
              <CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
            }

            {!isLoading && filtered.map((item, i) => {
              if (isGroup(item)) {
                return (
                  <CommandGroup
                    key={item.group}
                    heading={item.group}
                    className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
                  >
                    {item.options.map((opt, j) => (
                      <Item
                        key={getKey(opt, j)}
                        option={opt}
                        selected={value.includes(getValue(opt))}
                        onSelect={onSelect}
                        indicatorAt={indicatorAt}
                        className={itemCls}
                      />
                    ))}
                  </CommandGroup>
                )
              }

              return (
                <Item
                  key={getKey(item, i)}
                  option={item}
                  selected={value.includes(getValue(item))}
                  onSelect={onSelect}
                  indicatorAt={indicatorAt}
                  className={cn("mx-1 mb-1", itemCls)}
                />
              )
            })}

            {value.length > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem onSelect={() => onValueChange([])} value="__clear__" className="justify-center">
                    Clear selection(s)
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

export {
  Combobox,
  MultiSelectCombobox,
  type comboboxProps,
  type multiSelectComboboxProps,
}

Done

You can now use Combobox

Usage

Basic

import { Combobox, MultiSelectCombobox } from "@/components/ui/combobox"

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

Controlled

import { useState } from "react"
import { Combobox, MultiSelectCombobox } from "@/components/ui/combobox"

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

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

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

Custom Styling

export function Styled() {
  return (
    <Combobox
      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

MultiSelectCombobox

Prop

Type