Frappe UIFrappe UI

Combobox

Lets users pick one option from a searchable list. To accept text that is not in the list, add a type: 'custom' row that sets the value — see Create New.

Playground

label
placeholder
trigger
size
variant
required
disabled
<Combobox
  label="Status"
  placeholder="Pick or search…"
  :options="options"
  v-model="value"
/>

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>

Search Row

In trigger="button" mode the search input moves into the popover header. That row carries data-slot="search" and exposes #search-prefix and #search-suffix, both receiving { query, setQuery, disabled, focus }.

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

const value = ref<string>('')

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Blueberry', value: 'blueberry' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Grape', value: 'grape' },
  { label: 'Orange', value: 'orange' },
]

function clearSearch(setQuery: (value: string) => void, focus: () => void) {
  setQuery('')
  focus()
}
</script>

<template>
  <Combobox
    v-model="value"
    :options="options"
    trigger="button"
    placeholder="Select a fruit"
    class="w-72"
  >
    <template #search-prefix>
      <span class="lucide-search size-4 shrink-0 text-ink-gray-5" />
    </template>

    <template #search-suffix="{ query, setQuery, focus }">
      <button
        v-if="query"
        type="button"
        aria-label="Clear search"
        class="grid size-5 shrink-0 place-items-center rounded-4 text-ink-gray-5 hover:bg-surface-gray-2 hover:text-ink-gray-8"
        @pointerdown.prevent
        @click="clearSearch(setQuery, focus)"
      >
        <span class="lucide-x size-3.5" />
      </button>
      <kbd
        v-else
        class="shrink-0 rounded-4 border border-outline-gray-2 bg-surface-gray-1 px-1.5 py-0.5 text-p-xs text-ink-gray-5"
      >
        ⌘ K
      </kbd>
    </template>
  </Combobox>
</template>

hideSearch removes the row entirely for short static lists. The #search-* slots live inside the row, so they disappear with it. In the default trigger="input" mode there is no in-popover row at all and hideSearch has no effect — the trigger is the search input.

Fetch options from a server as the user types. Bind v-model:query, debounce the request, and feed the results back into :options. The :loading prop swaps the result body for a loading state. Four things to watch for: pass :filterable="false" so the client doesn't substring-filter what the server already matched, drop stale responses with a request id so a slower earlier query can't overwrite the latest results, merge the selected item into the options array so the trigger stays resolvable after the query narrows the list, and clear the query yourself when the popover opens — see the note below on who owns it.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { Avatar, Combobox } from 'frappe-ui'

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

const ALL_MEMBERS: Member[] = [
  {
    label: 'Alex Rivera',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Alexandra Chen',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Priya Shah',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Priyanka Mehta',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Marcus Lee',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Maria Garcia',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Marketing',
  },
  {
    label: 'Sofia Hartmann',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Kenji Tanaka',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Nadia Okafor',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Diego Alvarez',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
]

// Mocks a server endpoint: 400ms latency + substring match on label/value.
// A real backend would rank and fuzzy-match, which is why the picker below
// passes `:filterable="false"` — a second client-side substring pass would
// silently drop anything the server matched but the query doesn't contain.
function searchMembersApi(query: string): Promise<Member[]> {
  return new Promise((resolve) => {
    setTimeout(() => {
      const q = query.trim().toLowerCase()
      const matches = q
        ? ALL_MEMBERS.filter(
            (m) =>
              m.label.toLowerCase().includes(q) ||
              m.value.toLowerCase().includes(q),
          )
        : ALL_MEMBERS
      resolve(matches.slice(0, 6))
    }, 400)
  })
}

const value = ref<string>('')
const query = ref('')
const results = ref<Member[]>([])
const loading = ref(false)
const knownById = ref(new Map<string, Member>())

let requestId = 0
async function fetchMembers(next: string) {
  const id = ++requestId
  loading.value = true
  const members = await searchMembersApi(next)
  // Drop stale responses so an earlier-but-slower request can't overwrite
  // the latest results.
  if (id !== requestId) return
  results.value = members
  for (const m of members) knownById.value.set(m.value, m)
  loading.value = false
}

const onQueryChange = useDebounceFn(fetchMembers, 250)

// Merge the selected member into the options so the trigger stays
// resolvable after the query narrows the result set.
const options = computed<Member[]>(() => {
  const byId = new Map<string, Member>()
  for (const m of results.value) byId.set(m.value, m)
  if (value.value && !byId.has(value.value)) {
    const existing = knownById.value.get(value.value)
    if (existing) byId.set(value.value, existing)
  }
  return Array.from(byId.values())
})

function onOpen(isOpen: boolean) {
  if (!isOpen) return
  // Binding `query` — or merely listening for `@update:query` — hands
  // ownership over, so the open-time reset the combobox does for an unbound
  // query is ours to do. Without it the committed label stays in the search
  // box and the next keystroke appends to it.
  query.value = ''
  if (results.value.length === 0) fetchMembers('')
}
</script>

<template>
  <!-- The `as` casts on the two handlers below exist because these model
       emits are declared twice; drop them when #1096 removes the duplicates. -->
  <Combobox
    v-model="value"
    v-model:query="query"
    :options="options"
    :loading="loading"
    :filterable="false"
    trigger="button"
    placeholder="Search members…"
    empty-text="No members found"
    class="w-80"
    @update:query="(q) => onQueryChange(q as string)"
    @update:open="(isOpen) => onOpen(isOpen as boolean)"
  >
    <template #item-prefix="{ item }">
      <Avatar :image="(item as Member).image" :label="item.label" size="sm" />
    </template>

    <template #item-label="{ item }">
      <div class="min-w-0 flex justify-between">
        <div class="truncate">{{ item.label }}</div>
        <div class="truncate text-p-sm text-ink-gray-5">
          {{ (item as Member).role }}
        </div>
      </div>
    </template>
  </Combobox>
</template>

A type: 'custom' row's condition callback is consumer-declared visibility rather than client filtering, so it keeps running with filterable: false.

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>

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-8',
  blue: 'bg-surface-blue-3',
  green: 'bg-surface-green-3',
  red: 'bg-surface-red-7',
}

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

// Matches both slot option shapes: a selectable option carries `value`, a
// custom option carries only `label`.
function getOptionValue(item: { value?: unknown; label: string }) {
  return String(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-1',
                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-1 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 — there is no prop for it, because what "create" means varies. 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 and set the model. Enter picks the row when it is the highlighted one, so typing and hitting Enter commits.

A value that matches no option is kept as-is: the trigger falls back to showing the raw string. That makes this the way to build a free-form "text input with autocomplete" too — the row commits the query, and the value survives.

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-1"
        />
        <span
          v-if="item.key === 'create'"
          class="rounded-1 bg-surface-gray-8 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: '[email protected]',
    email: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Priya Shah',
    value: '[email protected]',
    email: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Marcus Lee',
    value: '[email protected]',
    email: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Sofia Hartmann',
    value: '[email protected]',
    email: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Kenji Tanaka',
    value: '[email protected]',
    email: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    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-6"
        >
          <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-6">
          {{ 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>

The #footer slot renders below the list and stays pinned to the bottom of the popover — it does not scroll with the options. Scroll the list to confirm the footer remains fixed.

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

const value = ref('')

// A long list so the viewport scrolls — the footer should stay pinned to the
// bottom of the popover instead of scrolling away with the items (#717).
const countries = [
  'Argentina',
  'Australia',
  'Brazil',
  'Canada',
  'Denmark',
  'Egypt',
  'France',
  'Germany',
  'India',
  'Indonesia',
  'Japan',
  'Kenya',
  'Mexico',
  'Netherlands',
  'Norway',
  'Portugal',
  'Singapore',
  'Spain',
  'Sweden',
  'United Kingdom',
  'United States',
  'Vietnam',
]
</script>

<template>
  <div class="grid gap-3">
    <Combobox
      v-model="value"
      :options="countries"
      placeholder="Pick a country"
      open-on-focus
      class="w-64"
    >
      <template #footer="{ query, selectedOption, clear, setOpen }">
        <div
          class="flex items-center justify-between border-t border-outline-gray-1 px-3 py-2 text-sm text-ink-gray-5"
        >
          <span v-if="query">
            Searching “<span class="text-ink-gray-7">{{ query }}</span
            >
          </span>
          <span v-else>{{ countries.length }} countries</span>
          <button
            v-if="selectedOption"
            class="text-ink-gray-7 hover:text-ink-gray-8"
            @click="clear"
          >
            Clear
          </button>
          <button
            v-else
            class="text-ink-gray-7 hover:text-ink-gray-8"
            @click="setOpen(false)"
          >
            Close
          </button>
        </div>
      </template>
    </Combobox>

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

In Dialog

Combobox inside a Dialog. Focus returns to the trigger when the popover closes, even inside the Dialog's focus scope, so no extra wiring is needed.

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 #title>
      <h3 class="text-3xl-semibold text-ink-gray-9">
        Combobox inside Dialog
      </h3>
    </template>

    <template #default>
      <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-4 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>

Template Ref

A template ref exposes { clear, focus } — the same shape as Select and MultiSelect. focus() moves focus to the input in trigger="input" mode, and to the button in trigger="button" mode.

vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const picker = useTemplateRef('picker')

function reset() {
  picker.value?.clear()
  picker.value?.focus()
}
</script>

<template>
  <Combobox ref="picker" v-model="value" :options="options" />
</template>

clear() empties the selection and nothing else. In trigger="button" mode whatever you typed in the search box stays. In trigger="input" mode the input goes blank anyway, because there the query follows the model.

Notes

  • v-model:query is optional, but listening for @update:query already makes the query yours. There is no observe-only mode: an @update:query handler counts as binding it, so the combobox stops resetting the search box and the committed label stays in it — the next keystroke appends. If you listen, bind v-model:query too and clear it on @update:open. To read the query without owning it, use #search-prefix, #search-suffix, or #footer, which all hand it out.
  • In trigger="input" mode the input is the value display, so an unbound query keeps following the committed option's label — that is model sync, not a reset.
  • Use #item-prefix, #item-label, and #item-suffix to customize the standard option row; reach for #item only when you need to replace the row shell too.
  • For a single choice with no search, use Select; for several values, use MultiSelect.

API Reference

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

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

import type { PopoverSide, PopoverAlign } from '../shared/selection/types'
export type { PopoverSide, PopoverAlign }

/** Value accepted by a selectable option and by `v-model`. */
export type ComboboxOptionValue = string | number

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: ComboboxOptionValue
  icon?: string | Component
  description?: string
  disabled?: boolean
  /** Dispatches the row to the `#item-<slot>` template slot. */
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: ComboboxItemSlots<ComboboxItemSlotProps>
  [key: string]: any
}

export type ComboboxCustomOptionContext = {
  query: string
}

export type ComboboxCustomOption = {
  type: 'custom'
  key: string
  label: string
  icon?: string | Component
  description?: string
  disabled?: boolean
  /** Dispatches the row to the `#item-<slot>` template slot. */
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: ComboboxItemSlots<ComboboxItemSlotProps>
  onClick: (context: ComboboxCustomOptionContext) => void
  keepOpen?: boolean
  condition?: (context: ComboboxCustomOptionContext) => boolean
  [key: string]: any
}

export type ComboboxSimpleOption =
  | string
  | ComboboxSelectableOption
  | ComboboxCustomOption

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

export type ComboboxOption = ComboboxSimpleOption | ComboboxGroupedOption

export interface ComboboxProps extends InputLabelingProps {
  /** Committed value. `null` when nothing is selected. */
  modelValue?: ComboboxOptionValue | null

  /** 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

  /**
   * Controls the search query. Optional — the combobox owns it otherwise.
   *
   * When it is bound the consumer owns the query: the combobox never resets it
   * on its own — not on open, not on close, not on mount, not on `clear()`. It
   * still follows the committed option's label in `trigger="input"` mode, where
   * the input is the value display rather than a filter. Unbound,
   * `trigger="button"` mode still clears the search box every time the popover
   * opens.
   */
  query?: string

  /** 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. Unset, an embedding host's target is used, else `body`. */
  portalTo?: string | HTMLElement

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

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

  /**
   * Hides the in-popover search row (button mode only — in input mode the
   * trigger *is* the search input).
   *
   * The `#search-prefix` / `#search-suffix` slots live inside that row and
   * are not rendered when this is `true`.
   */
  hideSearch?: boolean

  /**
   * Client-side substring filtering of `options` as the user types.
   *
   * Set to `false` for pickers whose options come from a server search: the
   * backend already decided what matches, and a second literal substring
   * pass on the client silently drops fuzzy, ranked, or id-based results.
   *
   * A custom option's `condition` callback is consumer-declared visibility
   * rather than client filtering, so it keeps running either way.
   */
  filterable?: boolean
}

export interface ComboboxControlSlotProps {
  /** 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

  /**
   * Clears the current selection (sets the model to `null`) and nothing else.
   * The search query is left as it is; in `trigger="input"` mode the input
   * still empties, because there the query follows the model.
   */
  clear: () => void

  /** Sets the popover open state (no-op while disabled). */
  setOpen: (value: boolean) => void
}

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

  /** Updates the search query and emits `update:query`. */
  setQuery: (value: string) => void

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

  /** Moves focus to the in-popover search input. */
  focus: (options?: FocusOptions) => void
}

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
}

/**
 * Fixed slot names. Kept separate from `ComboboxSlots` so the dynamic
 * `` `item-${string}` `` index signature can be intersected in without
 * constraining names that don't match the pattern.
 */
interface ComboboxFixedSlots {
  /** Fully custom trigger renderer. */
  trigger?: (props: ComboboxControlSlotProps) => 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 the other control slots. */
  prefix?: (props: ComboboxControlSlotProps) => 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: ComboboxControlSlotProps) => any

  /**
   * Content rendered before the in-popover search input (button mode only).
   * Not rendered when `hideSearch` is set.
   */
  'search-prefix'?: (props: ComboboxSearchSlotProps) => any

  /**
   * Content rendered after the in-popover search input (button mode only).
   * Not rendered when `hideSearch` is set.
   */
  'search-suffix'?: (props: ComboboxSearchSlotProps) => 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. Stays pinned below the scrollable
   * options. */
  footer?: (props: ComboboxControlSlotProps) => any
}

/**
 * Item slot names: the three fixed regions of the row shell, plus any
 * `#item-<name>` dispatched from an option's `slot` field.
 *
 * The index signature is deliberately narrowed to `` `item-${string}` `` —
 * `ComboboxResults` resolves `item.slot` to `` `item-${item.slot}` ``, so this
 * is exactly the runtime behavior, and every fixed slot name stays typechecked
 * instead of every typo compiling clean.
 */
interface ComboboxItemSlotsByName {
  /** 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

  /** Per-option dynamic slot, selected by the option's `slot` field. */
  [slotName: `item-${string}`]:
    | ((props: ComboboxItemSlotProps) => any)
    | undefined
}

export interface ComboboxSlots
  extends ComboboxFixedSlots, ComboboxItemSlotsByName {}

export interface ComboboxEmits {
  /** Fired when the committed value changes. */
  'update:modelValue': [value: ComboboxOptionValue | null]

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

  /** Fired when the query changes. */
  '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]
}
modelValue
= null
ComboboxOptionValue | null

Committed value. `null` when nothing is selected.

options
= []
ComboboxOption[]

Options rendered in the popover.

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

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
= false
boolean

Controls the popover visibility.

query
= ""
string

Controls the search query. Optional — the combobox owns it otherwise. When it is bound the consumer owns the query: the combobox never resets it on its own — not on open, not on close, not on mount, not on `clear()`. It still follows the committed option's label in `trigger="input"` mode, where the input is the value display rather than a filter. Unbound, `trigger="button"` mode still clears the search box every time the popover opens.

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
= "start"
PopoverAlign

Preferred popover alignment.

offset
= 4
number

Gap between trigger and content.

portalTo
string | HTMLElement

Teleport target for the popover content. Unset, an embedding host's target is used, else `body`.

loading
= false
boolean

Replaces the results with a loading state.

emptyText
= "No results"
string

Fallback empty-state copy.

hideSearch
= false
boolean

Hides the in-popover search row (button mode only — in input mode the trigger *is* the search input). The `#search-prefix` / `#search-suffix` slots live inside that row and are not rendered when this is `true`.

filterable
= true
boolean

Client-side substring filtering of `options` as the user types. Set to `false` for pickers whose options come from a server search: the backend already decided what matches, and a second literal substring pass on the client silently drops fuzzy, ranked, or id-based results. A custom option's `condition` callback is consumer-declared visibility rather than client filtering, so it keeps running either way.

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. A `#description` slot is not: it renders beside the error, and is referenced alongside it.

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, with `sr-only` text that announces it, and forwards `required` / `aria-required` to the underlying control where the control's role allows it. `data-required` is set either way.

id
string

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

trigger
ComboboxControlSlotProps

Fully custom trigger renderer.

label
{ required: boolean; }

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

description

Overrides the rendered description content.

prefix
ComboboxControlSlotProps

Content rendered before the default input. Receives the same shape as the other control slots.

suffix
ComboboxControlSlotProps

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.

search-prefix
ComboboxSearchSlotProps

Content rendered before the in-popover search input (button mode only). Not rendered when `hideSearch` is set.

search-suffix
ComboboxSearchSlotProps

Content rendered after the in-popover search input (button mode only). Not rendered when `hideSearch` is set.

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
ComboboxControlSlotProps

Content rendered after the list. Stays pinned below the scrollable options.

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.

update:open
[value: boolean]

Fired when the open state changes.

blur
[event: FocusEvent]

Fired when the input loses focus.

focus
[event: FocusEvent]

Fired when the input receives focus.

update:modelValue
[value: ComboboxOptionValue | null]

Fired when the committed value changes.

update:query
[value: string]

Fired when the query changes.

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

Fired when the resolved selected option changes.