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 listbox. Can be binded with 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 select items.
valueKeyvaluestringThe key name to use to display in the selected value.
textEmptyNo items found.stringThe text to display when the combobox is empty.
by-string, ((a: AcceptableValue, b: AcceptableValue) => boolean)Use this to compare objects by a particular field, or pass your own comparison function for complete control over how objects are compared.
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

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 w-full justify-between font-normal [&>span]:truncate',
  'combobox-trigger-trailing': 'size-1.4285714285714286em 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 size-1.1428571428571428em) rtl:mr-auto ltr:ml-auto',
  'combobox-trigger-leading': 'size-1.1428571428571428em',

  'combobox-item': 'data-[highlighted]:bg-accent data-[highlighted]:text-accent relative flex cursor-default items-center gap-2 rounded-sm px-0.5714285714285714em py-0.42857142857142855em text-sm 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': 'min-w-200px',
  'combobox-empty': 'py-1.7142857142857142em text-center text-0.8571428571428571em',
  'combobox-group': 'overflow-hidden p-0.2857142857142857em text-foreground',
  'combobox-label': 'px-0.6666666666666666em py-0.5em text-0.8571428571428571em text-muted font-medium',
  'combobox-list': 'z-50 w-[--reka-popper-anchor-width] rounded-md border bg-popover text-popover overflow-hidden shadow-md outline-none 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',
  '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> extends Omit<ComboboxRootProps<ExtractItemType<T>>, 'modelValue'>, Pick<NComboboxInputProps, 'status' | 'id'>, BaseExtensions {
  /**
   * The model value for the combobox.
   * When using grouped items, this will be the item type from within the groups.
   */
  modelValue?: ExtractItemType<T> | ExtractItemType<T>[] | null | undefined

  /**
   * The items to display in the combobox.
   *
   * @default []
   */
  items?: T[] | NComboboxGroupProps<ExtractItemType<T>>[]
  /**
   * The key name to use to display in the select items.
   *
   * @default 'label'
   */
  labelKey?: keyof ExtractItemType<T>
  /**
   * The key name to use to display in the selected value.
   *
   * @default 'value'
   */
  valueKey?: keyof ExtractItemType<T>
  /**
   * 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
  /**
   * 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 { ComboboxRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '../../utils'
</script>

<script setup lang="ts" generic="T extends AcceptableValue">
import { computed } 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>>(), {
  textEmpty: 'No items found.',
  size: 'md',
})
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)

const labelKey = computed(() => props.labelKey?.toString() ?? 'label')
const valueKey = computed(() => props.valueKey?.toString() ?? 'value')

// 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]
}

// Find a matching item from the items list by its value
function findItemByValue(value: unknown): ExtractItemType<T> | undefined {
  if (!props.items)
    return undefined

  if (hasGroups.value) {
    // Search in grouped items
    for (const group of props.items as NComboboxGroupProps<ExtractItemType<T>>[]) {
      const found = group.items?.find(item => getItemProperty(item, valueKey.value) === value)
      if (found)
        return found
    }
    return undefined
  }
  else {
    // Search in flat items list
    return (props.items as ExtractItemType<T>[]).find(item => getItemProperty(item, valueKey.value) === value)
  }
}

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

  // Handle multiple selection (array values)
  if (props.multiple && Array.isArray(val)) {
    return val.map((v) => {
      // For primitive values (string/number), find matching item to get label
      if (typeof v !== 'object' || v === null) {
        const item = findItemByValue(v)
        return item ? getItemProperty(item, labelKey.value) : v
      }

      // For objects, try to get the label directly
      return getItemProperty(v, labelKey.value) || getItemProperty(v, valueKey.value) || ''
    }).filter(Boolean).join(', ')
  }

  // For single primitive value
  if (typeof val !== 'object' || val === null) {
    const item = findItemByValue(val)
    return item ? getItemProperty(item, labelKey.value) : String(val || '')
  }

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

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

  const itemValue = getItemProperty(item, valueKey.value)

  // For multiple selection
  if (props.multiple && Array.isArray(props.modelValue)) {
    return props.modelValue.includes(itemValue)
  }

  // For single selection
  return typeof props.modelValue === 'object' && props.modelValue !== null
    ? getItemProperty(props.modelValue as ExtractItemType<T>, valueKey.value) === itemValue
    : props.modelValue === itemValue
}
</script>

<template>
  <ComboboxRoot
    v-slot="{ modelValue, open }"
    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(
                  'text-0.875em',
                  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
                :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"
              :size
              :class="cn(
                'border-0 border-b-1 rounded-none focus-visible:ring-0',
                props._comboboxInput?.class,
              )"
              v-bind="props._comboboxInput"
            />
          </slot>

          <slot name="header" />

          <slot name="body">
            <ComboboxViewport
              v-bind="props._comboboxViewport"
              :una
            >
              <ComboboxEmpty
                v-bind="props._comboboxEmpty"
                :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, valueKey)"
                      :value="props.multiple ? getItemProperty(item, valueKey) : item"
                      :size
                      v-bind="props._comboboxItem"
                      :class="cn(
                        'text-0.875em',
                        props._comboboxItem?.class,
                      )"
                      :una
                    >
                      <slot name="item" :item="item" :selected="isItemSelected(item)">
                        <slot name="label" :item="item">
                          {{ getItemProperty(item, labelKey) }}
                        </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, valueKey)"
                      :value="props.multiple ? getItemProperty(item, valueKey) : item"
                      :size
                      v-bind="{ ...props._comboboxItem, ...group._comboboxItem }"
                      :class="cn(
                        'text-0.875em',
                        props._comboboxItem?.class,
                      )"
                      :una
                    >
                      <slot name="item" :item="item" :group="group" :selected="isItemSelected(item)">
                        <slot name="label" :item="item">
                          {{ getItemProperty(item, labelKey) }}
                        </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>