Combobox

Autocomplete input and command palette with a list of suggestions.

Examples

Basic

PropDefaultTypeDescription
items-T[] | NComboboxGroupProps<ExtractItemType<T>>[]The items to display in the combobox.
modelValue-AcceptableValue | AcceptableValue[]The controlled value of the combobox. Stores the full item object(s). Can be binded with v-model.
disabled-booleanWhen true, prevents the user from interacting with the combobox.
open-booleanThe controlled open state of the combobox. Can be binded with v-model.
label-stringThe heading to display for the grouped item.
labelKeylabelstringThe key name to use to display in the combobox items.
valueKeyvaluestringThe key name to use for comparing items (used for selection matching).
by-string | ((a: AcceptableValue, b: AcceptableValue) => boolean)Compare objects by a field or custom function. Use this to determine equality when selecting items.
textEmptyNo items found.stringThe text to display when the combobox is empty.
Preview
Code

Multiple

Allow users to select multiple items from the list.

PropDefaultTypeDescription
multiplefalsebooleanWhen true, allows the user to select multiple items.
Preview
Code

Trigger

Add a custom trigger content.

PropDefaultTypeDescription
_comboboxTrigger{ btn: 'solid-white', trailing: 'i-lucide-chevrons-up-down' }NComboboxTriggerPropsThe button props for the trigger, you can refer to the Button component for more details.
Preview
Code

List / Content

PropDefaultTypeDescription
_comboboxList{ align: 'center', sideOffset: 4, position: 'popper' }NComboboxListPropsProps for customizing the dropdown list of the combobox. Controls alignment, offset distance from trigger, positioning behavior and more.
Preview
Code

Create a new project

Group

PropsDefaultTypeDescription
Preview
Code

Form Field

Use the NFormField component to create a form field.

Preview
Code

Select a framework without a trigger

Size

Adjust the combobox size without limits. Use breakpoints (e.g., sm:sm, xs:lg) for responsive sizes or states (e.g., hover:lg, focus:3xl) for state-based sizes.

PropDefaultTypeDescription
sizesmstringAdjusts the overall size of the combobox component.
_comboboxInput.sizesmstringCustomizes the size of the combobox input element.
_comboboxItem.sizesmstringCustomizes the size of each item within the combobox dropdown.
_comboboxTrigger.sizesmstringModifies the size of the combobox trigger element.
Preview
Code

Slots

NamePropsDescription
default-Slot for advanced custom rendering using sub-components.
triggermodelValue, openCustom content inside the default trigger button.
trigger-wrappermodelValue, openCompletely replace the default trigger button/component.
input-wrappermodelValue, openCompletely replace the default input component.
itemitem, selectedCustom rendering for the entire content of each combobox item.
labelitemCustom rendering for the label text within each item.
indicatoritemCustom rendering for the selection indicator within each item.
header-Content rendered inside the list, before the items.
body-Completely replace the default item list container (Viewport).
footer-Content rendered inside the list, after the items.

Custom Rendering

Use the default slot for full control over the combobox's structure. This allows you to compose the combobox using its individual sub-components (like ComboboxInput, ComboboxList, etc., listed in the Components section), similar to libraries like shadcn/ui.

Preview
Code

Custom Multiple Selection

Customize the multiple selection content.

Preview
Code

Expose

NameTypeDescription
viewportRefRef<HTMLDivElement | null>Reference to the ComboboxViewport wrapper component.

Infinite Scroll

Implement infinite scrolling to load more items as the user scrolls. Access the scrollable viewport element using the viewportRef exposed property.

Preview
Code

Presets

shortcuts/combobox.ts
type ComboboxPrefix = 'combobox'

export const staticCombobox: Record<`${ComboboxPrefix}-${string}` | ComboboxPrefix, string> = {
// base
  'combobox': 'flex',
  'combobox-trigger-info-icon': 'i-info',
  'combobox-trigger-error-icon': 'i-error',
  'combobox-trigger-success-icon': 'i-success',
  'combobox-trigger-warning-icon': 'i-warning',
  'combobox-trigger-trailing-icon': 'i-lucide-chevrons-up-down',
  'combobox-input-leading-icon': 'i-lucide-search',

  'combobox-trigger': 'px-0.8571428571428571em min-w-200px w-full justify-between font-normal [&>span]:truncate',
  'combobox-trigger-trailing': 'size-1.1428571428571428em data-[status=error]:text-error data-[status=success]:text-success data-[status=warning]:text-warning data-[status=info]:text-info data-[status=default]:(n-disabled) rtl:mr-auto ltr:ml-auto',
  'combobox-trigger-leading': 'size-1.1428571428571428em',

  'combobox-item': 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-0.5em py-0.375em text-1em outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
  'combobox-item-indicator': 'ml-auto',
  'combobox-item-indicator-icon': '',
  'combobox-item-indicator-icon-name': 'i-check',
  'combobox-anchor': 'w-full',
  'combobox-empty': 'py-1.7142857142857142em text-center text-1em leading-1.4285714285714286em',
  'combobox-group': 'overflow-hidden p-0.2857142857142857em text-foreground',
  'combobox-label': 'px-0.6666666666666666em py-0.5em text-0.8571428571428571em leading-1.1428571428571428em text-muted-foreground font-medium',
  'combobox-list': 'z-50 w-[--reka-popper-anchor-width] rounded-md border bg-popover text-popover-foreground overflow-hidden shadow-md outline-none 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',
  'combobox-separator': 'bg-border -mx-1 h-px',
  'combobox-viewport': 'max-h-300px scroll-py-1 overflow-x-hidden overflow-y-auto',
}

export const dynamicCombobox: [RegExp, (params: RegExpExecArray) => string][] = [
// dynamic preset
]

export const combobox = [
  ...dynamicCombobox,
  staticCombobox,
]

Props

types/combobox.ts
import type { AcceptableValue, ComboboxAnchorProps, ComboboxContentProps, ComboboxEmptyProps, ComboboxGroupProps, ComboboxInputProps, ComboboxItemIndicatorProps, ComboboxItemProps, ComboboxLabelProps, ComboboxPortalProps, ComboboxRootProps, ComboboxSeparatorProps, ComboboxTriggerProps, ComboboxViewportProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'
import type { NCheckboxProps } from './checkbox'
import type { NInputProps } from './input'

interface BaseExtensions {
  class?: HTMLAttributes['class']
  size?: HTMLAttributes['class']
}

// Extract the actual item type when dealing with grouped items
export type ExtractItemType<T> = T extends { items: infer I extends AcceptableValue[] } ? I[number] : T

export interface NComboboxProps<T extends AcceptableValue, M extends boolean> extends Omit<ComboboxRootProps<ExtractItemType<T>>, 'modelValue' | 'defaultValue'>, Pick<NComboboxInputProps, 'status' | 'id'>, BaseExtensions {
  /**
   * The model value for the combobox.
   * Stores the full item object(s), not extracted values.
   */
  modelValue?: M extends true ? ExtractItemType<T>[] | null : ExtractItemType<T> | null

  /**
   * The default value for the combobox.
   * Should be the full item object(s), not extracted values.
   */
  defaultValue?: M extends true ? ExtractItemType<T>[] | null : ExtractItemType<T> | null

  /**
   * The items to display in the combobox.
   *
   * @default []
   */
  items?: T[] | NComboboxGroupProps<ExtractItemType<T>>[]
  /**
   * The key name to use to display in the combobox items.
   *
   * @default 'label'
   */
  labelKey?: keyof ExtractItemType<T> | string
  /**
   * The key name to use for comparing items (used for selection matching).
   *
   * @default 'value'
   */
  valueKey?: keyof ExtractItemType<T> | string
  /**
   * Whether to show a separator between groups.
   *
   * @default false
   */
  groupSeparator?: boolean
  /**
   * The text to display when the combobox is empty.
   *
   * @default 'No items found.'
   */
  textEmpty?: string
  /**
   * The heading to display for the grouped item.
   *
   * @default ''
   */
  label?: string

  multiple?: M

  /**
   * Sub-component configurations
   */
  _comboboxAnchor?: NComboboxAnchorProps
  _comboboxEmpty?: NComboboxEmptyProps
  _comboboxGroup?: NComboboxGroupProps<ExtractItemType<T>>
  _comboboxInput?: NComboboxInputProps
  _comboboxItem?: NComboboxItemProps<ExtractItemType<T>>
  _comboboxItemIndicator?: NComboboxItemIndicatorProps
  _comboboxLabel?: NComboboxLabelProps
  _comboboxList?: NComboboxListProps
  _comboboxSeparator?: NComboboxSeparatorProps
  _comboboxTrigger?: NComboboxTriggerProps
  _comboboxViewport?: NComboboxViewportProps
  _comboboxCheckbox?: NCheckboxProps
  _comboboxContent?: ComboboxContentProps
  _comboboxPortal?: ComboboxPortalProps
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/combobox.ts
   */
  una?: NComboboxUnaProps
}

export interface NComboboxLabelProps extends ComboboxLabelProps, BaseExtensions {
  label?: string
  una?: Pick<NComboboxUnaProps, 'comboboxLabel'>
}

export interface NComboboxItemProps<T> extends ComboboxItemProps<T>, BaseExtensions {
  una?: Pick<NComboboxUnaProps, 'comboboxItem'>
}

export interface NComboboxAnchorProps extends ComboboxAnchorProps, BaseExtensions {
  una?: Pick<NComboboxUnaProps, 'comboboxAnchor'>
}

export interface NComboboxEmptyProps extends ComboboxEmptyProps, BaseExtensions {
  una?: Pick<NComboboxUnaProps, 'comboboxEmpty'>
}

export interface NComboboxGroupProps<T extends AcceptableValue> extends ComboboxGroupProps, BaseExtensions {
  label?: string
  items?: T[]
  _comboboxItem?: Partial<NComboboxItemProps<T>>
  _comboboxLabel?: Partial<NComboboxLabelProps>
  una?: Pick<NComboboxUnaProps, 'comboboxGroup' | 'comboboxLabel'>
}

export interface NComboboxInputProps extends ComboboxInputProps, Omit<NInputProps, 'modelValue'> {
  [key: string]: any
}

export interface NComboboxItemIndicatorProps extends ComboboxItemIndicatorProps, BaseExtensions {
  icon?: HTMLAttributes['class']
  una?: Pick<NComboboxUnaProps, 'comboboxItemIndicator' | 'comboboxItemIndicatorIcon'>
}

export interface NComboboxListProps extends ComboboxContentProps, BaseExtensions {
  viewportClass?: HTMLAttributes['class']
  una?: Pick<NComboboxUnaProps, 'comboboxList'>
  _comboboxPortal?: ComboboxPortalProps
}

export interface NComboboxSeparatorProps extends ComboboxSeparatorProps, BaseExtensions {
  una?: Pick<NComboboxUnaProps, 'comboboxSeparator'>
}

export interface NComboboxTriggerProps extends ComboboxTriggerProps, NButtonProps {
  /**
   * The unique id of the select trigger to be used for the form field.
   */
  id?: string
  /**
   * The status of the select input.
   */
  status?: 'info' | 'success' | 'warning' | 'error'
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/combobox.ts
   */
  una?: Pick<NComboboxUnaProps, 'comboboxTrigger' | 'comboboxTriggerLeading' | 'comboboxTriggerTrailing' | 'comboboxTriggerInfoIcon' | 'comboboxTriggerSuccessIcon' | 'comboboxTriggerWarningIcon' | 'comboboxTriggerErrorIcon'> & NButtonProps['una']
}

export interface NComboboxViewportProps extends ComboboxViewportProps, BaseExtensions {
  una?: Pick<NComboboxUnaProps, 'comboboxViewport'>
}

export interface NComboboxUnaProps {
  combobox?: HTMLAttributes['class']
  comboboxAnchor?: HTMLAttributes['class']
  comboboxLabel?: HTMLAttributes['class']
  comboboxItem?: HTMLAttributes['class']
  comboboxItemIndicator?: HTMLAttributes['class']
  comboboxItemIndicatorIcon?: HTMLAttributes['class']
  comboboxSeparator?: HTMLAttributes['class']
  comboboxViewport?: HTMLAttributes['class']
  comboboxEmpty?: HTMLAttributes['class']
  comboboxGroup?: HTMLAttributes['class']
  comboboxList?: HTMLAttributes['class']
  comboboxTrigger?: HTMLAttributes['class']
  comboboxTriggerLeading?: HTMLAttributes['class']
  comboboxTriggerTrailing?: HTMLAttributes['class']
  comboboxTriggerInfoIcon?: HTMLAttributes['class']
  comboboxTriggerSuccessIcon?: HTMLAttributes['class']
  comboboxTriggerWarningIcon?: HTMLAttributes['class']
  comboboxTriggerErrorIcon?: HTMLAttributes['class']
}

Components

Combobox.vue
ComboboxAnchor.vue
ComboboxTrigger.vue
ComboboxInput.vue
ComboboxList.vue
ComboboxViewport.vue
ComboboxEmpty.vue
ComboboxGroup.vue
ComboboxLabel.vue
ComboboxItem.vue
ComboboxItemIndicator.vue
ComboboxSeparator.vue
<script lang="ts">
import type { AcceptableValue, ComboboxRootEmits } from 'reka-ui'
import type { ExtractItemType, NComboboxGroupProps, NComboboxProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { defu } from 'defu'
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '../../utils'
</script>

<script setup lang="ts" generic="T extends AcceptableValue, M extends boolean = false">
import { computed, toRef, useTemplateRef } from 'vue'
import ComboboxAnchor from './ComboboxAnchor.vue'
import ComboboxEmpty from './ComboboxEmpty.vue'
import ComboboxGroup from './ComboboxGroup.vue'
import ComboboxInput from './ComboboxInput.vue'
import ComboboxItem from './ComboboxItem.vue'
import ComboboxItemIndicator from './ComboboxItemIndicator.vue'
import ComboboxList from './ComboboxList.vue'
import ComboboxSeparator from './ComboboxSeparator.vue'
import ComboboxTrigger from './ComboboxTrigger.vue'
import ComboboxViewport from './ComboboxViewport.vue'

const props = withDefaults(defineProps<NComboboxProps<T, M>>(), {
  textEmpty: 'No items found.',
  size: 'sm',
  labelKey: 'label',
  valueKey: 'value',
})
const emits = defineEmits<ComboboxRootEmits<ExtractItemType<T>>>()

const rootProps = reactiveOmit(props, [
  'items',
  'una',
  'size',
  'label',
  'labelKey',
  'valueKey',
  'groupSeparator',
  'textEmpty',
  '_comboboxAnchor',
  '_comboboxEmpty',
  '_comboboxGroup',
  '_comboboxInput',
  '_comboboxItem',
  '_comboboxItemIndicator',
  '_comboboxLabel',
  '_comboboxList',
  '_comboboxSeparator',
  '_comboboxTrigger',
  '_comboboxViewport',
  '_comboboxCheckbox',
])

const forwarded = useForwardPropsEmits(rootProps, emits)

// Check if items are grouped
const hasGroups = computed(() => {
  return Array.isArray(props.items) && props.items.length > 0
    && typeof props.items[0] === 'object' && 'items' in (props.items[0] as any)
})

// Helper function to safely get a property from an item
function getItemProperty<K extends string>(item: ExtractItemType<T> | null | undefined, key: K): any {
  if (item == null)
    return ''

  return typeof item !== 'object' ? item : (item as Record<K, unknown>)[key]
}

// Display function that handles both single and multiple selections
function getDisplayValue(val: unknown): string {
  // Handle empty values (only null/undefined)
  if (val == null || (Array.isArray(val) && val.length === 0))
    return ''

  // Handle multiple selection (array values)
  if (Array.isArray(val)) {
    return val
      .map((v) => {
        // For primitive values (string/number/boolean), convert to string
        if (typeof v !== 'object' || v === null) {
          return String(v)
        }
        // For objects, try to get the label
        return getItemProperty(v, props.labelKey as string) || getItemProperty(v, props.valueKey as string) || ''
      })
      .filter(v => v !== null && v !== undefined)
      .join(', ')
  }

  // For single primitive value (preserve 0, false, '')
  if (typeof val !== 'object' || val === null) {
    return String(val)
  }

  // For single object, get its label
  return getItemProperty(val as any, props.labelKey as string) || getItemProperty(val as any, props.valueKey as string) || ''
}

// Check if an item is selected in the current modelValue
function isItemSelected(item: ExtractItemType<T> | null | undefined): boolean {
  if (item == null || !props.modelValue)
    return false

  // For primitive items (strings/numbers)
  if (typeof item !== 'object') {
    if (Array.isArray(props.modelValue)) {
      return props.modelValue.includes(item as any)
    }
    return props.modelValue === item
  }

  // For object items, compare by valueKey
  const itemValue = getItemProperty(item, props.valueKey as string)

  // For multiple selection
  if (Array.isArray(props.modelValue)) {
    return props.modelValue.some((v: any) => {
      if (typeof v !== 'object' || v === null) {
        return v === itemValue
      }
      return getItemProperty(v as ExtractItemType<T>, props.valueKey as string) === itemValue
    })
  }

  // For single selection
  if (typeof props.modelValue !== 'object' || props.modelValue === null) {
    return props.modelValue === itemValue
  }

  return getItemProperty(props.modelValue as ExtractItemType<T>, props.valueKey as string) === itemValue
}

const viewportRef = useTemplateRef('viewportRef')

defineExpose({
  viewportRef: toRef(() => viewportRef.value?.viewportRef),
})
</script>

<template>
  <ComboboxRoot
    data-slot="combobox"
    :class="cn(
      'combobox',
      props.una?.combobox,
      props.class,
    )"
    v-bind="forwarded"
  >
    <slot>
      <ComboboxAnchor
        v-bind="props._comboboxAnchor"
        :una
      >
        <slot name="anchor">
          <template
            v-if="$slots.trigger || $slots.triggerRoot"
          >
            <slot name="trigger-wrapper">
              <ComboboxTrigger
                v-bind="props._comboboxTrigger"
                :id
                :status
                :class="cn(
                  props._comboboxTrigger?.class,
                )"
                :size
              >
                <slot name="trigger" :model-value :open />
              </ComboboxTrigger>
            </slot>
          </template>

          <template v-else>
            <slot name="input-wrapper" :model-value :open>
              <ComboboxInput
                :id
                :display-value="(val: unknown) => getDisplayValue(val)"
                name="frameworks"
                :status
                :class="cn(
                  'text-1em',
                  props._comboboxInput?.class,
                )"
                :una="defu(props._comboboxInput?.una, {
                  inputLeading: 'text-1.1428571428571428em',
                  inputTrailing: 'text-1.1428571428571428em',
                  inputStatusIconBase: 'text-1.1428571428571428em',
                })"
                :size
                v-bind="props._comboboxInput"
              />
            </slot>
          </template>
        </slot>
      </ComboboxAnchor>

      <ComboboxList
        v-bind="{ ...props._comboboxList, ...props._comboboxContent }"
        :_combobox-portal
        :size
        :una
      >
        <slot name="list">
          <slot name="input-wrapper" :model-value :open>
            <ComboboxInput
              v-if="$slots.trigger || $slots.triggerRoot"
              input="~"
              :class="cn(
                'border-b-1 rounded-none text-1em',
                props._comboboxInput?.class,
              )"
              leading="combobox-input-leading-icon"
              :una="defu(props._comboboxInput?.una, {
                inputLeading: 'text-1.1428571428571428em',
                inputTrailing: 'text-1.1428571428571428em',
                inputStatusIconBase: 'text-1.1428571428571428em',
              })"
              :size
              v-bind="props._comboboxInput"
            />
          </slot>

          <slot name="header" />

          <slot name="body">
            <ComboboxViewport
              v-bind="props._comboboxViewport"
              ref="viewportRef"
              :una
            >
              <ComboboxEmpty
                v-bind="props._comboboxEmpty"
                :class="cn(
                  props._comboboxEmpty?.class,
                )"
                :size
                :una
              >
                <slot name="empty">
                  {{ props.textEmpty }}
                </slot>
              </ComboboxEmpty>

              <!-- Non-grouped items -->
              <template v-if="!hasGroups">
                <ComboboxGroup
                  v-bind="props._comboboxGroup"
                  :label="props.label"
                  :una
                >
                  <slot name="group">
                    <ComboboxItem
                      v-for="item in items as ExtractItemType<T>[]"
                      :key="getItemProperty(item, props.valueKey as string)"
                      :value="item"
                      :size
                      v-bind="props._comboboxItem"
                      :class="cn(
                        props._comboboxItem?.class,
                      )"
                      :una
                    >
                      <slot name="item" :item="item" :selected="isItemSelected(item)">
                        <slot name="label" :item="item">
                          {{ getItemProperty(item, props.labelKey as string) }}
                        </slot>

                        <ComboboxItemIndicator
                          v-bind="props._comboboxItemIndicator"
                          :una
                        >
                          <slot name="item-indicator" :item="item">
                            <NIcon name="i-lucide-check" />
                          </slot>
                        </ComboboxItemIndicator>
                      </slot>
                    </ComboboxItem>
                  </slot>
                </ComboboxGroup>
              </template>

              <!-- Grouped items -->
              <template v-else>
                <ComboboxGroup
                  v-for="(group, i) in items as NComboboxGroupProps<ExtractItemType<T>>[]"
                  :key="i"
                  v-bind="props._comboboxGroup"
                  :label="group.label"
                  :una
                >
                  <ComboboxSeparator
                    v-if="i > 0 && props.groupSeparator"
                    v-bind="props._comboboxSeparator"
                    :una
                  />

                  <slot name="group" :group="group">
                    <ComboboxItem
                      v-for="item in group.items"
                      :key="getItemProperty(item, props.valueKey as string)"
                      :value="item"
                      :size
                      v-bind="{ ...props._comboboxItem, ...group._comboboxItem }"
                      :class="cn(
                        props._comboboxItem?.class,
                      )"
                      :una
                    >
                      <slot name="item" :item="item" :group="group" :selected="isItemSelected(item)">
                        <slot name="label" :item="item">
                          {{ getItemProperty(item, props.labelKey as string) }}
                        </slot>

                        <ComboboxItemIndicator
                          v-bind="props._comboboxItemIndicator"
                          :una
                        >
                          <slot name="indicator" :item="item" />
                        </ComboboxItemIndicator>
                      </slot>
                    </ComboboxItem>
                  </slot>
                </ComboboxGroup>
              </template>
            </ComboboxViewport>
          </slot>

          <slot name="footer" />
        </slot>
      </ComboboxList>
    </slot>
  </ComboboxRoot>
</template>