Combobox

Lets users choose from available options or type their own. Provides clear, responsive feedback for every interaction.

Simple

A plain repo picker — just pass options as an array of strings.

Selected: frappe-ui
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref('frappe-ui')

const repos = [
  'gameplan',
  'frappe-ui',
  'frappe',
  'erpnext',
  'helpdesk',
  'crm',
  'wiki',
  'insights',
]
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="repos"
      placeholder="Pick a repo"
      open-on-focus
    />

    <div class="text-sm text-ink-gray-5">
      Selected: <code class="text-ink-gray-7">{{ value || 'None' }}</code>
    </div>
  </div>
</template>

Emoji Picker

Button-triggered combobox via trigger="button". The search input moves into the popover header. The button's label and prefix auto-derive from the selected option — #item-prefix doubles as the selected-state prefix, and #prefix is the placeholder icon shown before anything is picked.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref<string>('')

const emojis = [
  {
    group: 'Smileys',
    options: [
      { label: 'Grinning', value: 'grinning', icon: '😀' },
      { label: 'Laughing', value: 'laughing', icon: '😂' },
      { label: 'Heart Eyes', value: 'heart-eyes', icon: '😍' },
      { label: 'Thinking', value: 'thinking', icon: '🤔' },
      { label: 'Mind Blown', value: 'mind-blown', icon: '🤯' },
    ],
  },
  {
    group: 'Gestures',
    options: [
      { label: 'Thumbs Up', value: 'thumbs-up', icon: '👍' },
      { label: 'Clap', value: 'clap', icon: '👏' },
      { label: 'Party', value: 'party', icon: '🎉' },
      { label: 'Rocket', value: 'rocket', icon: '🚀' },
      { label: 'Fire', value: 'fire', icon: '🔥' },
    ],
  },
  {
    group: 'Objects',
    options: [
      { label: 'Sparkles', value: 'sparkles', icon: '' },
      { label: 'Bulb', value: 'bulb', icon: '💡' },
      { label: 'Warning', value: 'warning', icon: '⚠️' },
      { label: 'Check', value: 'check', icon: '' },
      { label: 'Cross', value: 'cross', icon: '' },
    ],
  },
]
</script>

<template>
  <Combobox
    v-model="value"
    trigger="button"
    :options="emojis"
    placeholder="Pick a reaction"
  >
    <template #prefix>
      <span class="lucide-smile size-4 text-ink-gray-6" />
    </template>
  </Combobox>
</template>

Grouped Options

Options split into named groups. #item-prefix renders a colored swatch per row.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

type Space = { label: string; value: string; accent: string }

const value = ref<string>('platform-infra')

const spaces: { group: string; options: Space[] }[] = [
  {
    group: 'Engineering',
    options: [
      {
        label: 'Platform Infra',
        value: 'platform-infra',
        accent: 'bg-blue-500',
      },
      { label: 'Mobile 2.0', value: 'mobile-2', accent: 'bg-red-500' },
      { label: 'Growth', value: 'growth', accent: 'bg-amber-500' },
    ],
  },
  {
    group: 'Product',
    options: [
      { label: 'Discovery', value: 'discovery', accent: 'bg-cyan-500' },
      { label: 'Roadmap', value: 'roadmap', accent: 'bg-green-500' },
      { label: 'Feedback', value: 'feedback', accent: 'bg-violet-500' },
    ],
  },
  {
    group: 'Design',
    options: [
      { label: 'System', value: 'system', accent: 'bg-teal-500' },
      { label: 'Research', value: 'research', accent: 'bg-pink-500' },
      { label: 'Brand', value: 'brand', accent: 'bg-orange-500' },
    ],
  },
]
</script>

<template>
  <Combobox
    v-model="value"
    :options="spaces"
    placeholder="Move to space…"
    class="w-72"
  >
    <template #item-prefix="{ item }">
      <div
        :class="['size-2.5 rounded-[3px]', (item as Space).accent]"
        aria-hidden="true"
      />
    </template>
  </Combobox>
</template>

Custom Value

Free-form acceptance via allowCustomValue: the typed query becomes the model value when nothing matches, and unknown external values are preserved. The component renders a built-in "Create X" row as a click affordance. Use this when you want a "text input with autocomplete" feel. For richer create-new UX (custom label, icon, persistence callback), see Create New below.

Selected: None
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref('')
const options = ['John Doe', 'Jane Doe', 'John Smith', 'Jane Smith']
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="options"
      :allow-custom-value="true"
      open-on-focus
      placeholder="Type a person"
      class="w-64"
    />

    <div class="text-sm text-gray-600">Selected: {{ value || 'None' }}</div>
  </div>
</template>

Clearable

Uses the #trigger slot to compose a custom trigger with an inline clear button. The X clears v-model via @click.stop so the popover doesn't toggle, and @pointerdown.stop keeps the anchor from intercepting the press.

A
vue
<script setup lang="ts">
import { reactive } from 'vue'
import { Combobox } from 'frappe-ui'

type FieldOption = string | { label: string; value: string }
type Field = { key: string; label: string; options: FieldOption[] }

const fields: Field[] = [
  {
    key: 'colour',
    label: 'colour',
    options: ['gray', 'blue', 'green', 'red'],
  },
  { key: 'size', label: 'size', options: ['sm', 'md', 'lg', 'xl'] },
  { key: 'style', label: 'style', options: ['subtle', 'outline', 'ghost'] },
  {
    key: 'fontSize',
    label: 'font size',
    options: [
      { label: '12px', value: '12' },
      { label: '14px', value: '14' },
      { label: '16px', value: '16' },
      { label: '20px', value: '20' },
      { label: '24px', value: '24' },
    ],
  },
]

const values = reactive<Record<string, string>>({
  colour: 'gray',
  size: 'sm',
  style: 'ghost',
  fontSize: '14',
})

const colourSwatch: Record<string, string> = {
  gray: 'bg-surface-gray-5',
  blue: 'bg-surface-blue-3',
  green: 'bg-surface-green-3',
  red: 'bg-surface-red-5',
}

const sizeDot: Record<string, string> = {
  sm: 'size-1.5',
  md: 'size-2',
  lg: 'size-2.5',
  xl: 'size-3',
}

const fontSizePx: Record<string, number> = {
  '12': 8,
  '14': 10,
  '16': 12,
  '20': 14,
  '24': 16,
}

function getOptionValue(item: { value?: string; label: string }) {
  return item.value ?? item.label
}

function clear(key: string, event: Event) {
  event.stopPropagation()
  values[key] = ''
}
</script>

<template>
  <div class="grid w-[420px] gap-2">
    <div
      v-for="field in fields"
      :key="field.key"
      class="group grid grid-cols-[8rem_1fr] items-center gap-4"
    >
      <label class="text-base text-ink-gray-5">{{ field.label }}</label>

      <Combobox
        v-model="values[field.key]"
        :options="field.options"
        variant="outline"
        :placeholder="`Pick a ${field.label}`"
        open-on-focus
      >
        <template #item-prefix="{ item }">
          <span class="grid size-4 shrink-0 place-items-center">
            <span
              v-if="field.key === 'colour'"
              :class="[
                'inline-block size-3 rounded-sm',
                colourSwatch[getOptionValue(item)] ?? 'bg-surface-gray-3',
              ]"
            />

            <span
              v-else-if="field.key === 'size'"
              :class="[
                'inline-block rounded-full bg-surface-gray-4',
                sizeDot[getOptionValue(item)] ?? 'size-2',
              ]"
            />

            <template v-else-if="field.key === 'style'">
              <span
                v-if="getOptionValue(item) === 'outline'"
                class="lucide-square-dashed size-4 text-ink-gray-6"
              />
              <span
                v-else-if="getOptionValue(item) === 'ghost'"
                class="lucide-circle-dashed size-4 text-ink-gray-6"
              />
              <span v-else class="lucide-square size-4 text-ink-gray-6" />
            </template>

            <span
              v-else-if="field.key === 'fontSize'"
              class="font-semibold leading-none text-ink-gray-7"
              :style="{
                fontSize: `${fontSizePx[getOptionValue(item)] ?? 12}px`,
              }"
            >
              A
            </span>
          </span>
        </template>

        <template #suffix="{ open }">
          <button
            v-if="values[field.key]"
            type="button"
            aria-label="Clear"
            tabindex="-1"
            class="grid size-4 place-items-center rounded-sm text-ink-gray-5 opacity-0 hover:bg-surface-gray-3 hover:text-ink-gray-7 group-hover:opacity-100 focus:opacity-100"
            @click="clear(field.key, $event)"
            @pointerdown.stop
          >
            <span class="lucide-x size-4" />
          </button>
          <span
            v-else
            :class="[
              'lucide-chevron-down size-4 text-ink-gray-5 transition-transform duration-200',
              open && 'rotate-180',
            ]"
          />
        </template>
      </Combobox>
    </div>
  </div>
</template>

Create New

"Create new" is just a type: 'custom' option. condition hides the row when the query is empty or already matches an existing item, and onClick receives the typed query so you can persist the new value.

Selected: None
Tags: bug, enhancement, docs, discussion
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from 'frappe-ui'

const value = ref<string>('')
const tags = ref<string[]>(['bug', 'enhancement', 'docs', 'discussion'])

const selectableOptions = computed(() =>
  tags.value.map((t) => ({ label: t, value: t })),
)

// "Create new" is just a `type: 'custom'` row. `condition` is authoritative
// — it runs even before the user types, so the row can decide for itself
// when to appear based on the typed query and current selection.
const options = computed(() => [
  ...selectableOptions.value,
  {
    type: 'custom' as const,
    key: 'create',
    label: 'Create tag',
    slot: 'create',
    keepOpen: false,
    condition: ({ query }: { query: string }) => {
      const q = query.trim().toLowerCase()
      if (!q) return false
      if (q === value.value?.toLowerCase()) return false
      return !tags.value.some((t) => t.toLowerCase() === q)
    },
    onClick: ({ query }: { query: string }) => {
      const next = query.trim()
      if (!next) return
      tags.value = [...tags.value, next]
      value.value = next
    },
  },
])

function getBgClass(item: { label: string }) {
  const palette = [
    'bg-surface-amber-3',
    'bg-surface-blue-3',
    'bg-surface-green-3',
    'bg-surface-gray-3',
  ]
  const hash = item.label
    .toLowerCase()
    .split('')
    .reduce((a, b) => a + b.charCodeAt(0), 0)
  return palette[hash % palette.length]
}
</script>

<template>
  <div class="grid gap-3 shrink-0">
    <Combobox
      v-model="value"
      :options="options"
      placeholder="Search or create a tag"
      open-on-focus
      class="w-64"
    >
      <template #item-prefix="{ item }">
        <span
          v-if="item.key !== 'create'"
          :class="getBgClass(item)"
          class="size-3 rounded-sm"
        />
        <span
          v-if="item.key === 'create'"
          class="rounded-sm bg-surface-gray-5 lucide-tag"
        />
      </template>
      <template #item-create="{ query }">
        <div class="flex">
          <span class="truncate">
            Create
            <span v-if="query" class="font-medium text-ink-gray-8">
              {{ query }}
            </span>
          </span>
        </div>
      </template>
    </Combobox>

    <div class="text-sm text-ink-gray-5">
      Selected: <code class="text-ink-gray-7">{{ value || 'None' }}</code>
    </div>
    <div class="text-sm text-ink-gray-5">
      Tags: <code class="text-ink-gray-7">{{ tags.join(', ') }}</code>
    </div>
  </div>
</template>

Status Picker

Dotted indicator aligned to the first line, with supporting description text.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from 'frappe-ui'

type StatusOption = {
  label: string
  value: string
  color: string
  description: string
}

const value = ref<string>('in-progress')

const statuses: StatusOption[] = [
  {
    label: 'Backlog',
    value: 'backlog',
    color: 'bg-gray-400',
    description: 'Ideas and future work',
  },
  {
    label: 'Todo',
    value: 'todo',
    color: 'bg-gray-500',
    description: 'Ready to be picked up',
  },
  {
    label: 'In Progress',
    value: 'in-progress',
    color: 'bg-blue-500',
    description: 'Actively being worked on',
  },
  {
    label: 'In Review',
    value: 'in-review',
    color: 'bg-yellow-500',
    description: 'Awaiting feedback',
  },
  {
    label: 'Done',
    value: 'done',
    color: 'bg-green-500',
    description: 'Shipped and verified',
  },
  {
    label: 'Cancelled',
    value: 'cancelled',
    color: 'bg-gray-300',
    description: 'Will not be worked on',
  },
]

const selected = computed(
  () => statuses.find((s) => s.value === value.value) ?? null,
)
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="statuses"
      placeholder="Set status"
      open-on-focus
      class="w-72"
    >
      <template #prefix>
        <span
          v-if="selected"
          :class="['size-2 rounded-full', selected.color]"
          aria-hidden="true"
        />
      </template>

      <!--
        The dot is rendered inside the label region so it aligns with the
        first line of text (not the vertical center of a two-line row).
      -->
      <template #item-label="{ item }">
        <div class="flex items-start gap-2">
          <span
            :class="[
              'mt-[4px] size-2 shrink-0 rounded-full',
              (item as StatusOption).color,
            ]"
            aria-hidden="true"
          />
          <div class="min-w-0">
            <div class="truncate">{{ item.label }}</div>
            <div class="truncate text-p-sm text-ink-gray-5">
              {{ (item as StatusOption).description }}
            </div>
          </div>
        </div>
      </template>
    </Combobox>
  </div>
</template>

Member Picker

Avatar rows with a contextual invite action authored through a template slot.

No one assigned
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar, Combobox } from 'frappe-ui'

type Member = {
  label: string
  value: string
  email: string
  image: string
  role: string
}

const value = ref<string>('')
const lastAction = ref<string>('')

// Using pravatar.cc for stable, realistic avatar photos keyed by email.
const members: Member[] = [
  {
    label: 'Alex Rivera',
    value: 'alex@example.com',
    email: 'alex@example.com',
    image: 'https://i.pravatar.cc/80?u=alex@example.com',
    role: 'Engineering',
  },
  {
    label: 'Priya Shah',
    value: 'priya@example.com',
    email: 'priya@example.com',
    image: 'https://i.pravatar.cc/80?u=priya@example.com',
    role: 'Design',
  },
  {
    label: 'Marcus Lee',
    value: 'marcus@example.com',
    email: 'marcus@example.com',
    image: 'https://i.pravatar.cc/80?u=marcus@example.com',
    role: 'Product',
  },
  {
    label: 'Sofia Hartmann',
    value: 'sofia@example.com',
    email: 'sofia@example.com',
    image: 'https://i.pravatar.cc/80?u=sofia@example.com',
    role: 'Engineering',
  },
  {
    label: 'Kenji Tanaka',
    value: 'kenji@example.com',
    email: 'kenji@example.com',
    image: 'https://i.pravatar.cc/80?u=kenji@example.com',
    role: 'Design',
  },
]

// Members appear as regular selectable options. The invite row is a custom
// action with `condition: () => true` so the picker always offers it,
// regardless of the current query.
const options = [
  ...members,
  {
    type: 'custom' as const,
    key: 'invite',
    label: 'Invite new member',
    slot: 'invite',
    condition: () => true,
    onClick: ({ query }: { query: string }) => {
      lastAction.value = query ? `Invited "${query}"` : 'Opened invite dialog'
    },
  },
]

const selected = computed(
  () => members.find((m) => m.value === value.value) ?? null,
)
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="options"
      placeholder="Assign to…"
      open-on-focus
      class="w-80"
    >
      <template #prefix>
        <Avatar v-if="selected" :image="selected.image" size="sm" />
      </template>

      <template #item-prefix="{ item }">
        <Avatar
          v-if="item.type !== 'custom'"
          :image="(item as Member).image"
          :label="item.label"
          size="sm"
        />
        <div
          v-else
          class="flex size-6 items-center justify-center rounded-full bg-surface-blue-2 text-ink-blue-600"
        >
          <span class="lucide-user-plus size-3.5" />
        </div>
      </template>

      <template #item-label="{ item }">
        <div v-if="item.type !== 'custom'" class="min-w-0">
          <div class="truncate">{{ item.label }}</div>
          <div class="truncate text-p-sm text-ink-gray-5">
            {{ (item as Member).email }}
          </div>
        </div>
      </template>

      <template #item-invite="{ query }">
        <span class="truncate text-ink-blue-600">
          {{ query ? `Invite "${query}"` : 'Invite new member' }}
        </span>
      </template>
    </Combobox>

    <div class="text-sm text-ink-gray-5">
      {{
        selected
          ? `Assigned to ${selected.label}`
          : lastAction || 'No one assigned'
      }}
    </div>
  </div>
</template>

In Dialog

Combobox rendered inside a Dialog. Verifies focus restores to the trigger after the popover closes, even when wrapped by the Dialog's focus scope.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Combobox, Dialog } from 'frappe-ui'

const open = ref(false)
const repo = ref('frappe-ui')
const reaction = ref('')

const repos = [
  'gameplan',
  'frappe-ui',
  'frappe',
  'erpnext',
  'helpdesk',
  'crm',
  'wiki',
  'insights',
]

const emojis = [
  {
    group: 'Smileys',
    options: [
      { label: 'Grinning', value: 'grinning', icon: '😀' },
      { label: 'Laughing', value: 'laughing', icon: '😂' },
      { label: 'Heart Eyes', value: 'heart-eyes', icon: '😍' },
      { label: 'Thinking', value: 'thinking', icon: '🤔' },
    ],
  },
  {
    group: 'Gestures',
    options: [
      { label: 'Thumbs Up', value: 'thumbs-up', icon: '👍' },
      { label: 'Party', value: 'party', icon: '🎉' },
      { label: 'Fire', value: 'fire', icon: '🔥' },
    ],
  },
]
</script>

<template>
  <Button @click="open = true">Open dialog</Button>

  <Dialog v-model="open">
    <template #body-title>
      <h3 class="text-2xl font-semibold text-ink-gray-9">
        Combobox inside Dialog
      </h3>
    </template>

    <template #body-content>
      <div class="space-y-4">
        <div class="flex flex-col gap-1">
          <label class="text-sm text-ink-gray-7">Repository</label>
          <Combobox
            v-model="repo"
            :options="repos"
            placeholder="Pick a repo"
            open-on-focus
          />
        </div>

        <div class="flex flex-col gap-1">
          <label class="text-sm text-ink-gray-7">Reaction</label>
          <Combobox
            v-model="reaction"
            trigger="button"
            :options="emojis"
            placeholder="Pick a reaction"
          >
            <template #prefix>
              <span class="lucide-smile size-4 text-ink-gray-6" />
            </template>
          </Combobox>
        </div>

        <div class="rounded bg-surface-gray-1 p-3 text-sm text-ink-gray-7">
          <div>
            Repo: <code>{{ repo || 'None' }}</code>
          </div>
          <div>
            Reaction: <code>{{ reaction || 'None' }}</code>
          </div>
        </div>
      </div>
    </template>

    <template #actions="{ close }">
      <Button variant="solid" @click="close">Done</Button>
    </template>
  </Dialog>
</template>

Label, Description, Error

Combobox supports label, description, error, and required directly — no FormControl wrapper needed. The error suppresses the description and wires aria-invalid + aria-errormessage onto the input.

Start typing to filter.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Checkbox, Combobox } from 'frappe-ui'

const value = ref<string | null>(null)
const required = ref(true)
const showError = ref(false)

const error = computed(() => (showError.value ? 'Please pick an option.' : ''))

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Durian', value: 'durian' },
]
</script>

<template>
  <div class="flex gap-8 items-start">
    <Combobox
      v-model="value"
      :options="options"
      label="Favourite fruit"
      description="Start typing to filter."
      :error="error"
      :required="required"
      placeholder="Pick one"
      class="w-72"
    />
    <div
      class="flex flex-col gap-2 items-start border-l border-outline-gray-2 pl-6"
    >
      <Checkbox v-model="required" label="required" />
      <Checkbox v-model="showError" label="show error" />
    </div>
  </div>
</template>

API Reference

Show types
typescript
import type { Component, VNode, VNodeChild } from 'vue'
import type { InputLabelingProps } from '../../composables/useInputLabeling'

export type ComboboxVariant = 'subtle' | 'outline' | 'ghost'
export type ComboboxSize = 'sm' | 'md' | 'lg' | 'xl'

export type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
export type PopoverAlign = 'start' | 'center' | 'end'

/** @deprecated alias for `align` */
export type ComboboxPlacement = PopoverAlign

export type ComboboxSlotFn<TProps> = (props: TProps) => VNodeChild

export interface ComboboxItemSlots<TProps> {
  /** Replaces the prefix region of the standard row shell. */
  prefix?: ComboboxSlotFn<TProps>

  /** Replaces the label region of the standard row shell. */
  label?: ComboboxSlotFn<TProps>

  /** Replaces the suffix region of the standard row shell. */
  suffix?: ComboboxSlotFn<TProps>

  /** Replaces the entire row; mutually exclusive with `prefix` / `label` / `suffix`. */
  item?: ComboboxSlotFn<TProps>
}

export type ComboboxSelectableOption = {
  type?: 'option'
  label: string
  value: string
  icon?: string | Component
  description?: string
  disabled?: boolean
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: ComboboxItemSlots<ComboboxItemSlotProps>
  /** @deprecated use `slot` */
  slotName?: string
  /** @deprecated use `slots` — function form maps to `slots.item`, object form to `slots` */
  render?: (() => VNode | VNode[]) | ComboboxItemSlots<ComboboxItemSlotProps>
  [key: string]: any
}

export type ComboboxCustomOptionContext = {
  query: string
  /** @deprecated use `query` */
  searchTerm: string
}

export type ComboboxCustomOption = {
  type: 'custom'
  key: string
  label: string
  icon?: string | Component
  description?: string
  disabled?: boolean
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: ComboboxItemSlots<ComboboxItemSlotProps>
  /** @deprecated use `slot` */
  slotName?: string
  onClick: (context: ComboboxCustomOptionContext) => void
  keepOpen?: boolean
  condition?: (context: ComboboxCustomOptionContext) => boolean
  /** @deprecated use `slots` — function form maps to `slots.item`, object form to `slots` */
  render?: (() => VNode | VNode[]) | ComboboxItemSlots<ComboboxItemSlotProps>
  [key: string]: any
}

export type SelectableOption = ComboboxSelectableOption
export type CustomOption = ComboboxCustomOption

export type ComboboxSimpleOption =
  | string
  | ComboboxSelectableOption
  | ComboboxCustomOption

export type SimpleOption = ComboboxSimpleOption

export interface ComboboxGroupedOption {
  key?: string | number
  group: string
  hideLabel?: boolean
  options: ComboboxSimpleOption[]
}

export type GroupedOption = ComboboxGroupedOption
export type ComboboxOption = ComboboxSimpleOption | ComboboxGroupedOption

export interface ComboboxProps extends InputLabelingProps {
  /** Options rendered in the popover. */
  options?: ComboboxOption[]

  /**
   * Shape of the trigger.
   * - `'input'` (default): user types directly into the trigger
   * - `'button'`: render a button trigger; search input moves into the
   *   popover header. Label + prefix auto-derive from the selected option.
   */
  trigger?: 'input' | 'button'

  /** Visual style of the combobox. */
  variant?: ComboboxVariant

  /** Size of the trigger and option rows. */
  size?: ComboboxSize

  /** Placeholder text shown when no value is selected. */
  placeholder?: string

  /** Disables the combobox. */
  disabled?: boolean

  /** Controls the popover visibility. */
  open?: boolean

  /** Opens the popover when the input receives focus. */
  openOnFocus?: boolean

  /** Opens the popover when the input is clicked. */
  openOnClick?: boolean

  /** Preferred popover side. */
  side?: PopoverSide

  /** Preferred popover alignment. */
  align?: PopoverAlign

  /** Gap between trigger and content. */
  offset?: number

  /** Teleport target for the popover content. */
  portalTo?: string | HTMLElement

  /**
   * Free-form acceptance: the typed query is accepted as the model value
   * when nothing matches, and external `modelValue` updates with unknown
   * strings are preserved. The combobox also renders a built-in "Create X"
   * row as a click affordance.
   *
   * For richer create-new UX (custom label / icon / persistence callback),
   * prefer a `type: 'custom'` option with `condition` instead — see the
   * Create New story. The two are independent and can be combined.
   */
  allowCustomValue?: boolean

  /** Replaces the results with a loading state. */
  loading?: boolean

  /** Fallback empty-state copy. */
  emptyText?: string

  /**
   * Alignment of the popover along the trigger edge.
   * @deprecated use `align` instead; `placement` is kept as a back-compat alias
   */
  placement?: ComboboxPlacement
}

export interface ComboboxTriggerSlotProps {
  /** Whether the popover is open. */
  open: boolean

  /** Whether the combobox is disabled. */
  disabled: boolean

  /** Current input query. */
  query: string

  /** Resolved selected option, if any. */
  selectedOption: ComboboxSelectableOption | null

  /** Resolved display text for the committed value. */
  displayValue: string
}

/**
 * Shared shape for `#trigger`, `#prefix`, and `#suffix`. `selectedOption`
 * is always `null` in `#prefix` because the prefix only renders before a
 * selection — the field is still exposed for slot-prop symmetry across
 * the trio.
 */
export type ComboboxSlotProps = ComboboxTriggerSlotProps
export type ComboboxPrefixSlotProps = ComboboxSlotProps
export type ComboboxSuffixSlotProps = ComboboxSlotProps

export interface ComboboxItemSlotProps {
  /** Item currently being rendered. */
  item: ComboboxSelectableOption | ComboboxCustomOption

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Whether the item is selected. */
  selected: boolean
}

export interface ComboboxGroupLabelSlotProps {
  /** Group currently being rendered. */
  group: ComboboxGroupedOption
}

export interface ComboboxEmptySlotProps {
  /** Current search query — empty when the user hasn't typed since opening. */
  query: string
}

export interface ComboboxSlots {
  /** Fully custom trigger renderer. */
  trigger?: (props: ComboboxTriggerSlotProps) => any

  /** Overrides the rendered label content. Receives `{ required }`. */
  label?: (props: { required: boolean }) => any

  /** Overrides the rendered description content. */
  description?: () => any

  /** Content rendered before the default input. Receives the same shape
   * as `#trigger` and `#suffix` (`ComboboxSlotProps`). */
  prefix?: (props: ComboboxPrefixSlotProps) => any

  /**
   * Content rendered after the input (input mode) or label (button mode).
   * Providing this slot **replaces the default chevron** — render your
   * own fallback (e.g. the chevron) when your slot content is conditional.
   * Common use: an inline clear button. Use `@click.stop` and
   * `@pointerdown.stop` so the press doesn't toggle the popover.
   */
  suffix?: (props: ComboboxSuffixSlotProps) => any

  /** Shared content rendered before the standard row label. */
  'item-prefix'?: (props: ComboboxItemSlotProps) => any

  /** Shared content rendered for the standard row label area. */
  'item-label'?: (props: ComboboxItemSlotProps) => any

  /** Shared content rendered after the standard row label area. */
  'item-suffix'?: (props: ComboboxItemSlotProps) => any

  /** Replaces the entire row. */
  item?: (props: ComboboxItemSlotProps) => any

  /** Custom renderer for group labels. */
  'group-label'?: (props: ComboboxGroupLabelSlotProps) => any

  /** Fallback content rendered when there are no results. */
  empty?: (props: ComboboxEmptySlotProps) => any

  /** Content rendered after the list. */
  footer?: () => any

  [slotName: string]: ((props: any) => any) | undefined
}

export interface ComboboxEmits {
  /** Fired when the open state changes. */
  'update:open': [value: boolean]

  /** Fired when the query changes due to user input. */
  'update:query': [value: string]

  /** Fired when the resolved selected option changes. */
  'update:selectedOption': [
    option: ComboboxSelectableOption | ComboboxCustomOption | null,
  ]

  /** Fired when the input receives focus. */
  focus: [event: FocusEvent]

  /** Fired when the input loses focus. */
  blur: [event: FocusEvent]

  /** @deprecated compatibility alias for `update:query`. */
  input: [value: string]
}

export interface ComboboxExposed {
  reset: () => void
}
options
= []
ComboboxOption[]

Options rendered in the popover.

trigger
= "input"
"button" | "input"

Shape of the trigger. - `'input'` (default): user types directly into the trigger - `'button'`: render a button trigger; search input moves into the popover header. Label + prefix auto-derive from the selected option.

variant
= "subtle"
ComboboxVariant

Visual style of the combobox.

size
= "sm"
ComboboxSize

Size of the trigger and option rows.

placeholder
= "Select option"
string

Placeholder text shown when no value is selected.

disabled
= false
boolean

Disables the combobox.

open
boolean

Controls the popover visibility.

openOnFocus
= false
boolean

Opens the popover when the input receives focus.

openOnClick
= true
boolean

Opens the popover when the input is clicked.

side
= "bottom"
PopoverSide

Preferred popover side.

align
PopoverAlign

Preferred popover alignment.

offset
= 4
number

Gap between trigger and content.

portalTo
= "body"
string | HTMLElement

Teleport target for the popover content.

allowCustomValue
= false
boolean

Free-form acceptance: the typed query is accepted as the model value when nothing matches, and external `modelValue` updates with unknown strings are preserved. The combobox also renders a built-in "Create X" row as a click affordance. For richer create-new UX (custom label / icon / persistence callback), prefer a `type: 'custom'` option with `condition` instead — see the Create New story. The two are independent and can be combined.

loading
= false
boolean

Replaces the results with a loading state.

emptyText
= "No results"
string

Fallback empty-state copy.

placement

Deprecated — use `align` instead; `placement` is kept as a back-compat alias

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set.

error
string | FrappeUIError

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).

required
boolean

Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

modelValue
= null
string | null
trigger
ComboboxTriggerSlotProps

Fully custom trigger renderer.

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description

Overrides the rendered description content.

prefix
ComboboxTriggerSlotProps

Content rendered before the default input. Receives the same shape as `#trigger` and `#suffix` (`ComboboxSlotProps`).

suffix
ComboboxTriggerSlotProps

Content rendered after the input (input mode) or label (button mode). Providing this slot **replaces the default chevron** — render your own fallback (e.g. the chevron) when your slot content is conditional. Common use: an inline clear button. Use `@click.stop` and `@pointerdown.stop` so the press doesn't toggle the popover.

item-prefix
ComboboxItemSlotProps

Shared content rendered before the standard row label.

item-label
ComboboxItemSlotProps

Shared content rendered for the standard row label area.

item-suffix
ComboboxItemSlotProps

Shared content rendered after the standard row label area.

item
ComboboxItemSlotProps

Replaces the entire row.

group-label
ComboboxGroupLabelSlotProps

Custom renderer for group labels.

empty
ComboboxEmptySlotProps

Fallback content rendered when there are no results.

footer

Content rendered after the list.

update:modelValue
[value: string | null]

Fired when the model value changes.

update:query
[value: string]

Fired when the query changes.

input
[value: string]
update:open
[value: boolean]

Fired when the open state changes.

update:selectedOption
[option: ComboboxSelectableOption | ComboboxCustomOption | null]

Fired when the selected option changes.

focus
[event: FocusEvent]
blur
[event: FocusEvent]