HeadlessCombobox
headlessAn accessible, renderless combobox. It renders nothing on its own — the default scoped slot exposes state, ARIA prop bags, and actions so you own 100% of the markup and styling. Supports single or multiple selection (with min/max counts) and reactive validation.
Installation
pnpm add @pdanpdan/headless-comboboxExamples
Each popup uses the native Popover API (top-layer, via popover="manual") with the exposed popupStyle for anchor positioning. The keyboard-active and mouse-hovered option share the same highlight (via setHighlightedIndex on hover); the selection is shown separately with a check.
Text options · single · no filter
A list of plain string options, single selection, no search input.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
const frameworks = [
'Vue',
'React',
'Svelte',
'Solid',
'Angular',
'Preact',
'Qwik',
'Lit',
'Alpine',
'Ember',
];
const selected = ref<string | null>('Vue');
const labelId = useId();
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, isSelected, canSelectMore,
cssAnchorName, popupStyle, triggerProps, listboxProps,
getOptionProps, setContainerRef, setTriggerRef, setDropdownRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="frameworks"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Framework (single, no filter)</legend>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex w-full cursor-pointer items-center justify-between shadow-sm"
:style="{ anchorName: cssAnchorName }"
>
<span :class="{ 'text-base-content/50': selected == null }">{{ selected ?? 'Select a framework…' }}</span>
<svg
class="h-4 w-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
</fieldset>
<ul
:ref="(el) => { setDropdownRef(el); setListRef(el); }"
v-bind="listboxProps"
popover="manual"
class="cbx-popup menu max-h-60 flex-nowrap gap-0.5 rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<li
v-for="(framework, index) in filteredOptions"
:key="framework"
>
<button
:ref="(el) => setOptionRef(framework, el)"
type="button"
v-bind="getOptionProps(framework, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(framework),
'hover:brightness-95': isSelected(framework),
'menu-focus': index === highlightedIndex,
'bg-primary/20': index === highlightedIndex,
'text-primary': index === highlightedIndex,
'cursor-pointer': canSelectMore || isSelected(framework),
}"
>
<span>{{ framework }}</span>
<svg
v-if="isSelected(framework)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
</ul>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Object options · single · with filter
Object options with a custom layout and a searchable input.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
interface User {
id: number;
name: string;
role: string;
}
const users = ref<User[]>([
{ id: 1, name: 'Wade Cooper', role: 'Admin' },
{ id: 2, name: 'Arlene Mccoy', role: 'Editor' },
{ id: 3, name: 'Devon Webb', role: 'Viewer' },
{ id: 4, name: 'Tom Cook', role: 'Editor' },
{ id: 5, name: 'Tanya Fox', role: 'Admin' },
{ id: 6, name: 'Hellen Schmidt', role: 'Viewer' },
{ id: 7, name: 'Caroline Schultz', role: 'Admin' },
{ id: 8, name: 'Mason Heaney', role: 'Editor' },
]);
const selected = ref<User | null>(users.value[ 1 ] ?? null);
const labelId = useId();
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, searchQuery,
isSelected, canSelectMore, cssAnchorName, popupStyle, triggerProps, inputProps,
listboxProps, getOptionProps, setContainerRef, setTriggerRef, setDropdownRef, setInputRef,
setListRef, setOptionRef,
}"
v-model="selected"
:options="users"
:display-value="(u: User) => u.name"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Assign user (single, with filter)</legend>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex w-full cursor-pointer items-center justify-between shadow-sm"
:style="{ anchorName: cssAnchorName }"
>
<span v-if="selected">{{ selected.name }}</span>
<span v-else class="text-base-content/50">Select a user…</span>
<svg
class="h-4 w-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
</fieldset>
<div
:ref="setDropdownRef"
popover="manual"
class="cbx-popup rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<div class="border-b border-base-content/10 p-2">
<input
:ref="setInputRef"
v-bind="inputProps"
:value="searchQuery"
type="text"
class="input input-sm w-full"
placeholder="Search users…"
aria-label="Search users"
/>
</div>
<ul
:ref="setListRef"
v-bind="listboxProps"
class="menu max-h-60 w-full flex-nowrap gap-0.5 overflow-y-auto"
>
<li
v-for="(user, index) in filteredOptions"
:key="user.id"
>
<button
:ref="(el) => setOptionRef(user, el)"
type="button"
v-bind="getOptionProps(user, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(user),
'hover:brightness-95': isSelected(user),
'menu-focus': index === highlightedIndex,
'bg-primary/20': index === highlightedIndex,
'text-primary': index === highlightedIndex,
'cursor-pointer': canSelectMore || isSelected(user),
}"
>
<span class="flex flex-col items-start gap-0.5">
<span>{{ user.name }}</span>
<span class="text-xs opacity-60">{{ user.role }}</span>
</span>
<svg
v-if="isSelected(user)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
<li
v-if="filteredOptions.length === 0"
class="pointer-events-none p-4 text-center text-sm text-base-content/50"
role="presentation"
>
No users found.
</li>
</ul>
</div>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Text options · multiple · with filter
Multiple selection capped with max; selecting toggles, dropdown stays open.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
const frameworks = [
'Vue',
'React',
'Svelte',
'Solid',
'Angular',
'Preact',
'Qwik',
'Lit',
'Alpine',
'Ember',
];
const selected = ref<string[]>([ 'Vue', 'Svelte' ]);
const labelId = useId();
const MAX = 3;
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, searchQuery,
clear, isSelected, canSelectMore, selectedCount,
cssAnchorName, popupStyle, triggerProps, inputProps, listboxProps, getOptionProps, setContainerRef,
setTriggerRef, setDropdownRef, setInputRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="frameworks"
multiple
:max-length="MAX"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Frameworks (multiple, max {{ MAX }})</legend>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex h-auto min-h-10 w-full cursor-pointer items-center justify-between gap-2 py-1.5 shadow-sm"
:style="{ anchorName: cssAnchorName }"
>
<span v-if="selected.length" class="flex flex-wrap gap-1">
<span
v-for="item in selected"
:key="item"
class="badge badge-soft badge-primary badge-sm"
>{{ item }}</span>
</span>
<span v-else class="text-base-content/50">Select frameworks…</span>
<span class="text-xs opacity-60">{{ selectedCount }}/{{ MAX }}</span>
</button>
</fieldset>
<div
:ref="setDropdownRef"
popover="manual"
class="cbx-popup rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<div class="flex items-center gap-2 border-b border-base-content/10 p-2">
<input
:ref="setInputRef"
v-bind="inputProps"
:value="searchQuery"
type="text"
class="input input-sm flex-1"
placeholder="Search…"
aria-label="Search frameworks"
/>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="clear"
>
Clear
</button>
</div>
<ul
:ref="setListRef"
v-bind="listboxProps"
class="menu max-h-60 w-full flex-nowrap gap-0.5 overflow-y-auto"
>
<li
v-for="(framework, index) in filteredOptions"
:key="framework"
>
<button
:ref="(el) => setOptionRef(framework, el)"
type="button"
v-bind="getOptionProps(framework, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(framework),
'hover:brightness-95': isSelected(framework),
'menu-focus': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'bg-primary/20': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'text-primary': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'cursor-pointer': canSelectMore || isSelected(framework),
'pointer-events-none': !canSelectMore && !isSelected(framework),
'opacity-40': !canSelectMore && !isSelected(framework),
}"
>
<span>{{ framework }}</span>
<svg
v-if="isSelected(framework)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
</ul>
</div>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Text options · multiple · removable chips
Selected items as removable chips. The remove buttons live outside the combobox trigger, so the trigger stays a single focusable control.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
const frameworks = [
'Vue',
'React',
'Svelte',
'Solid',
'Angular',
'Preact',
'Qwik',
'Lit',
'Alpine',
'Ember',
];
const selected = ref<string[]>([ 'Vue', 'Svelte' ]);
const labelId = useId();
const MAX = 3;
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, searchQuery, select,
clear, isSelected, canSelectMore, selectedCount,
cssAnchorName, popupStyle, triggerProps, inputProps, listboxProps, getOptionProps, setContainerRef,
setTriggerRef, setDropdownRef, setInputRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="frameworks"
multiple
:max-length="MAX"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Frameworks (multiple, max {{ MAX }})</legend>
<div v-if="selected.length" class="mb-1.5 flex flex-wrap gap-1">
<span
v-for="item in selected"
:key="item"
class="badge badge-soft badge-primary gap-1 pr-1"
>
{{ item }}
<button
type="button"
class="btn btn-circle btn-ghost btn-xs"
:aria-label="`Remove ${ item }`"
@click="select(item)"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
</div>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex h-auto min-h-10 w-full cursor-pointer items-center justify-between gap-2 py-1.5 shadow-sm"
:style="{ anchorName: cssAnchorName }"
>
<span v-if="selected.length" class="text-base-content/70">{{ selected.length }} selected</span>
<span v-else class="text-base-content/50">Select frameworks…</span>
<span class="text-xs opacity-60">{{ selectedCount }}/{{ MAX }}</span>
</button>
</fieldset>
<div
:ref="setDropdownRef"
popover="manual"
class="cbx-popup rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<div class="flex items-center gap-2 border-b border-base-content/10 p-2">
<input
:ref="setInputRef"
v-bind="inputProps"
:value="searchQuery"
type="text"
class="input input-sm flex-1"
placeholder="Search…"
aria-label="Search frameworks"
/>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="clear"
>
Clear
</button>
</div>
<ul
:ref="setListRef"
v-bind="listboxProps"
class="menu max-h-60 w-full flex-nowrap gap-0.5 overflow-y-auto"
>
<li
v-for="(framework, index) in filteredOptions"
:key="framework"
>
<button
:ref="(el) => setOptionRef(framework, el)"
type="button"
v-bind="getOptionProps(framework, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(framework),
'hover:brightness-95': isSelected(framework),
'menu-focus': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'bg-primary/20': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'text-primary': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'cursor-pointer': canSelectMore || isSelected(framework),
'pointer-events-none': !canSelectMore && !isSelected(framework),
'opacity-40': !canSelectMore && !isSelected(framework),
}"
>
<span>{{ framework }}</span>
<svg
v-if="isSelected(framework)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
</ul>
</div>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Text options · multiple · custom options
Options are fully customizable: the focused option gets a thick left border, and each option shows a checkbox (checked / unchecked) for its selection state.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
const frameworks = [
'Vue',
'React',
'Svelte',
'Solid',
'Angular',
'Preact',
'Qwik',
'Lit',
'Alpine',
'Ember',
];
const selected = ref<string[]>([ 'Vue', 'Svelte' ]);
const labelId = useId();
const MAX = 3;
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, searchQuery,
clear, isSelected, canSelectMore, selectedCount,
cssAnchorName, popupStyle, triggerProps, inputProps, listboxProps, getOptionProps, setContainerRef,
setTriggerRef, setDropdownRef, setInputRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="frameworks"
multiple
:max-length="MAX"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Frameworks (multiple, custom options)</legend>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex h-auto min-h-10 w-full cursor-pointer items-center justify-between gap-2 py-1.5 shadow-sm"
:style="{ anchorName: cssAnchorName }"
>
<span v-if="selected.length" class="flex flex-wrap gap-1">
<span
v-for="item in selected"
:key="item"
class="badge badge-soft badge-primary badge-sm"
>{{ item }}</span>
</span>
<span v-else class="text-base-content/50">Select frameworks…</span>
<span class="text-xs opacity-60">{{ selectedCount }}/{{ MAX }}</span>
</button>
</fieldset>
<div
:ref="setDropdownRef"
popover="manual"
class="cbx-popup rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<div class="flex items-center gap-2 border-b border-base-content/10 p-2">
<input
:ref="setInputRef"
v-bind="inputProps"
:value="searchQuery"
type="text"
class="input input-sm flex-1"
placeholder="Search…"
aria-label="Search frameworks"
/>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="clear"
>
Clear
</button>
</div>
<ul
:ref="setListRef"
v-bind="listboxProps"
class="menu max-h-60 w-full flex-nowrap gap-0.5 overflow-y-auto"
>
<li
v-for="(framework, index) in filteredOptions"
:key="framework"
>
<button
:ref="(el) => setOptionRef(framework, el)"
type="button"
v-bind="getOptionProps(framework, index)"
class="justify-between shadow-none gap-2 border-l-2 rounded-xs"
:class="{
'menu-active': isSelected(framework),
'border-l-primary': index === highlightedIndex && (canSelectMore || isSelected(framework)),
'border-l-transparent': index !== highlightedIndex || !(canSelectMore || isSelected(framework)),
'cursor-pointer': canSelectMore || isSelected(framework),
'pointer-events-none': !canSelectMore && !isSelected(framework),
'opacity-40': !canSelectMore && !isSelected(framework),
}"
>
<span>{{ framework }}</span>
<span
class="checkbox checkbox-xs checkbox-primary rounded-xs"
:aria-checked="isSelected(framework)"
aria-hidden="true"
>
<!-- <svg
v-if="isSelected(framework)"
class="h-3 w-3 text-primary-content"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg> -->
</span>
</button>
</li>
</ul>
</div>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Object options · multiple · validated
Multiple selection with required + min/max validation and a rendered message.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
interface User {
id: number;
name: string;
role: string;
}
const users = ref<User[]>([
{ id: 1, name: 'Wade Cooper', role: 'Admin' },
{ id: 2, name: 'Arlene Mccoy', role: 'Editor' },
{ id: 3, name: 'Devon Webb', role: 'Viewer' },
{ id: 4, name: 'Tom Cook', role: 'Editor' },
{ id: 5, name: 'Tanya Fox', role: 'Admin' },
{ id: 6, name: 'Hellen Schmidt', role: 'Viewer' },
{ id: 7, name: 'Caroline Schultz', role: 'Admin' },
{ id: 8, name: 'Mason Heaney', role: 'Editor' },
]);
const selected = ref<User[]>([ users.value[ 1 ] as User ]);
const labelId = useId();
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, searchQuery,
isSelected, canSelectMore, selectedCount, valid, validationMessage,
cssAnchorName, popupStyle, triggerProps, inputProps, listboxProps, getOptionProps,
setContainerRef, setTriggerRef, setDropdownRef, setInputRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="users"
:display-value="(u: User) => u.name"
multiple
required
:min-length="2"
:max-length="4"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Reviewers (multiple, 2–4 required)</legend>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex w-full cursor-pointer items-center justify-between shadow-sm"
:class="valid ? '' : 'input-error'"
:style="{ anchorName: cssAnchorName }"
>
<span :class="{ 'text-base-content/50': selectedCount === 0 }">
{{ selectedCount ? `${ selectedCount } selected` : 'Select reviewers…' }}
</span>
<svg
class="h-4 w-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
<p
class="fieldset-label"
:class="valid ? 'text-success' : 'text-error'"
>
{{ valid ? 'Looks good.' : validationMessage }}
</p>
</fieldset>
<div
:ref="setDropdownRef"
popover="manual"
class="cbx-popup rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<div class="border-b border-base-content/10 p-2">
<input
:ref="setInputRef"
v-bind="inputProps"
:value="searchQuery"
type="text"
class="input input-sm w-full"
placeholder="Search reviewers…"
aria-label="Search reviewers"
/>
</div>
<ul
:ref="setListRef"
v-bind="listboxProps"
class="menu max-h-60 w-full flex-nowrap gap-0.5 overflow-y-auto"
>
<li
v-for="(user, index) in filteredOptions"
:key="user.id"
>
<button
:ref="(el) => setOptionRef(user, el)"
type="button"
v-bind="getOptionProps(user, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(user),
'hover:brightness-95': isSelected(user),
'menu-focus': index === highlightedIndex && (canSelectMore || isSelected(user)),
'bg-primary/20': index === highlightedIndex && (canSelectMore || isSelected(user)),
'text-primary': index === highlightedIndex && (canSelectMore || isSelected(user)),
'cursor-pointer': canSelectMore || isSelected(user),
'pointer-events-none': !canSelectMore && !isSelected(user),
'opacity-40': !canSelectMore && !isSelected(user),
}"
>
<span class="flex flex-col items-start gap-0.5">
<span>{{ user.name }}</span>
<span class="text-xs opacity-60">{{ user.role }}</span>
</span>
<svg
v-if="isSelected(user)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
</ul>
</div>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Select alignment
No search input; the dropdown aligns so the selected option overlays the trigger.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
interface User {
id: number;
name: string;
role: string;
}
const users = ref<User[]>([
{ id: 1, name: 'Wade Cooper', role: 'Admin' },
{ id: 2, name: 'Arlene Mccoy', role: 'Editor' },
{ id: 3, name: 'Devon Webb', role: 'Viewer' },
{ id: 4, name: 'Tom Cook', role: 'Editor' },
{ id: 5, name: 'Tanya Fox', role: 'Admin' },
{ id: 6, name: 'Hellen Schmidt', role: 'Viewer' },
{ id: 7, name: 'Caroline Schultz', role: 'Admin' },
{ id: 8, name: 'Mason Heaney', role: 'Editor' },
]);
const selected = ref<User | null>(users.value[ 3 ] ?? null);
const labelId = useId();
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
filteredOptions, highlightedIndex, isSelected, canSelectMore,
cssAnchorName, popupStyle, triggerProps, listboxProps,
getOptionProps, setContainerRef, setTriggerRef, setDropdownRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="users"
:display-value="(u: User) => u.name"
align-selected
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Assign user</legend>
<button
:ref="setTriggerRef"
v-bind="triggerProps"
:aria-labelledby="labelId"
type="button"
class="input flex w-full cursor-pointer items-center justify-between shadow-sm"
:style="{ anchorName: cssAnchorName }"
>
<span v-if="selected">{{ selected.name }}</span>
<span v-else class="text-base-content/50">Select a user…</span>
<svg
class="h-4 w-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
</fieldset>
<ul
:ref="(el) => { setDropdownRef(el); setListRef(el); }"
v-bind="listboxProps"
popover="manual"
class="cbx-popup menu max-h-60 flex-nowrap gap-0.5 rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<li
v-for="(user, index) in filteredOptions"
:key="user.id"
>
<button
:ref="(el) => setOptionRef(user, el)"
type="button"
v-bind="getOptionProps(user, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(user),
'hover:brightness-95': isSelected(user),
'menu-focus': index === highlightedIndex,
'bg-primary/20': index === highlightedIndex,
'text-primary': index === highlightedIndex,
'cursor-pointer': canSelectMore || isSelected(user),
}"
>
<span>{{ user.name }}</span>
<svg
v-if="isSelected(user)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
</ul>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup covers the control: zoom + fade via the Popover API.
`scale` composes with the inline `translate` alignment offset. */
.cbx-popup {
opacity: 0;
scale: 0.8;
transition:
opacity 0.2s ease,
scale 0.2s ease,
overlay 0.2s ease allow-discrete,
display 0.2s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
scale: 1;
@starting-style {
opacity: 0;
scale: 0.8;
}
}
}
</style>
Typeahead (editable combobox)
Canonical APG editable pattern: the text input itself is the combobox (role=combobox); type to filter.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref, useId } from 'vue';
const languages = [
'JavaScript',
'TypeScript',
'Python',
'Rust',
'Go',
'Java',
'C#',
'C++',
'Ruby',
'PHP',
'Swift',
'Kotlin',
'Dart',
'Elixir',
'Haskell',
'Scala',
];
const selected = ref<string | null>(null);
const labelId = useId();
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
isOpen, filteredOptions, highlightedIndex, searchQuery, toggle,
isSelected, canSelectMore, handleKeydown, cssAnchorName, popupStyle,
comboboxInputProps, listboxProps, getOptionProps, setContainerRef, setTriggerRef,
setInputRef, setDropdownRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="languages"
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Language (typeahead, editable)</legend>
<div
class="input flex w-full items-center gap-2"
:style="{ anchorName: cssAnchorName }"
>
<input
:ref="(el) => { setTriggerRef(el); setInputRef(el); }"
v-bind="comboboxInputProps"
:aria-labelledby="labelId"
:value="isOpen ? searchQuery : (selected ?? '')"
type="text"
class="grow"
placeholder="Search a language…"
@keydown="(e) => { if (e.key !== ' ') { handleKeydown(e); } }"
/>
<button
type="button"
tabindex="-1"
class="cursor-pointer text-base-content/50"
aria-label="Toggle suggestions"
@click="toggle"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
</fieldset>
<ul
:ref="(el) => { setDropdownRef(el); setListRef(el); }"
v-bind="listboxProps"
popover="manual"
class="cbx-popup menu max-h-60 flex-nowrap gap-0.5 rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<li
v-for="(language, index) in filteredOptions"
:key="language"
>
<button
:ref="(el) => setOptionRef(language, el)"
type="button"
v-bind="getOptionProps(language, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(language),
'hover:brightness-95': isSelected(language),
'menu-focus': index === highlightedIndex,
'bg-primary/20': index === highlightedIndex,
'text-primary': index === highlightedIndex,
'cursor-pointer': canSelectMore || isSelected(language),
}"
>
<span>{{ language }}</span>
<svg
v-if="isSelected(language)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
<li
v-if="filteredOptions.length === 0"
class="pointer-events-none p-4 text-center text-sm text-base-content/50"
role="presentation"
>
No matches.
</li>
</ul>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
Typeahead · chips inside the field
GitHub-style topic input: chips and the text input share one bordered field. The field is a plain container, so each chip can carry a remove button without nesting controls.
<script setup lang="ts">
import { HeadlessCombobox } from '@pdanpdan/headless-combobox';
import { nextTick, ref, useId, watch } from 'vue';
const languages = [
'JavaScript',
'TypeScript',
'Python',
'Rust',
'Go',
'Java',
'C#',
'C++',
'Ruby',
'PHP',
'Swift',
'Kotlin',
'Dart',
'Elixir',
'Haskell',
'Scala',
];
const selected = ref<string[]>([ 'TypeScript' ]);
const labelId = useId();
const chipsRef = ref<HTMLElement | null>(null);
// Keep the newest chip in view when the chip row overflows.
watch(selected, () => {
nextTick(() => {
const el = chipsRef.value;
if (el) {
el.scrollTo({ left: el.scrollWidth });
}
});
});
</script>
<template>
<div class="w-full max-w-sm">
<HeadlessCombobox
v-slot="{
isOpen, filteredOptions, highlightedIndex, searchQuery,
select, isSelected, canSelectMore, handleKeydown, cssAnchorName, popupStyle,
comboboxInputProps, listboxProps, getOptionProps, setContainerRef, setTriggerRef,
setInputRef, setDropdownRef, setListRef, setOptionRef,
}"
v-model="selected"
:options="languages"
multiple
>
<fieldset
:ref="setContainerRef"
class="fieldset w-full min-w-0"
>
<legend :id="labelId" class="fieldset-legend font-semibold">Topics (typeahead, removable chips in the field)</legend>
<div
class="input flex min-w-0 w-full items-center gap-1"
:style="{ anchorName: cssAnchorName }"
>
<div
ref="chipsRef"
class="flex min-w-0 items-center gap-1 overflow-x-auto"
>
<span
v-for="item in selected"
:key="item"
class="badge badge-soft badge-primary gap-1 pr-1"
>
{{ item }}
<button
type="button"
class="btn btn-circle btn-ghost btn-xs"
:aria-label="`Remove ${ item }`"
@mousedown.prevent
@click.stop="select(item)"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
</div>
<input
:ref="(el) => { setTriggerRef(el); setInputRef(el); }"
v-bind="comboboxInputProps"
:aria-labelledby="labelId"
:value="isOpen ? searchQuery : ''"
type="text"
class="min-w-24 flex-1"
placeholder="Add a topic…"
@keydown="(e) => {
if (e.key === 'Backspace' && !searchQuery && selected.length > 0) {
const last = selected[selected.length - 1];
if (last) { select(last); }
}
else if (e.key !== ' ') {
handleKeydown(e);
}
}"
/>
</div>
</fieldset>
<ul
:ref="(el) => { setDropdownRef(el); setListRef(el); }"
v-bind="listboxProps"
popover="manual"
class="cbx-popup menu max-h-60 flex-nowrap gap-0.5 rounded-box bg-base-200 shadow-xl"
:style="[popupStyle, {
top: 'calc(anchor(bottom) + 0.375rem)',
left: 'calc(anchor(left) - 0.25rem)',
width: 'calc(anchor-size(width) + 0.5rem)',
}]"
>
<li
v-for="(language, index) in filteredOptions"
:key="language"
>
<button
:ref="(el) => setOptionRef(language, el)"
type="button"
v-bind="getOptionProps(language, index)"
class="justify-between shadow-none hover:bg-primary/20 hover:text-primary"
:class="{
'menu-active': isSelected(language),
'hover:brightness-95': isSelected(language),
'menu-focus': index === highlightedIndex,
'bg-primary/20': index === highlightedIndex,
'text-primary': index === highlightedIndex,
'cursor-pointer': canSelectMore || isSelected(language),
}"
>
<span>{{ language }}</span>
<svg
v-if="isSelected(language)"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</button>
</li>
<li
v-if="filteredOptions.length === 0"
class="pointer-events-none p-4 text-center text-sm text-base-content/50"
role="presentation"
>
No matches.
</li>
</ul>
</HeadlessCombobox>
</div>
</template>
<style scoped>
/* Popup opens below the control: slide + fade via the Popover API. */
.cbx-popup {
opacity: 0;
translate: 0 -0.375rem;
transition:
opacity 0.15s ease,
translate 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -0.375rem;
}
}
}
</style>
API
Props
| Name | Type | Default | Description |
|---|---|---|---|
| modelValue | T | T[] | null | — | Selected option(s) (v-model). Array in multiple mode. |
| options | T[] | — | List of options. |
| multiple | boolean | false | Enable multiple selection. |
| minLength | number | — | Multiple: minimum number of selected options (validation). |
| maxLength | number | — | Multiple: maximum selected. Blocks adding beyond it. |
| required | boolean | false | Require a selection (single: a value; multiple: >= 1). |
| disabled | boolean | false | Disable the control: not focusable, cannot open or change. |
| readonly | boolean | false | Read-only: focusable, shows the value, but cannot open or change. |
| closeOnSelect | boolean | null | !multiple | Close the dropdown after selecting. |
| displayValue | (option: T) => string | String(option) | Maps an option to a string for filtering / rendering. |
| filterFn | (option: T, query: Q) => boolean | substring | Custom filter function. Q defaults to string. |
| id | string | useId() | Base id for accessibility attributes. |
| alignSelected | boolean | false | Align the dropdown so the selected option covers the trigger. |
| errorMessages | Partial<Record<HeadlessComboboxErrorCode, string>> | — | Override default validation messages. |
Emits
| Name | Type | Description |
|---|---|---|
| update:modelValue | T | T[] | null | Emitted when the selection changes. |
Slot default
Renderless. Receives the scope below to build the entire UI.
State
Reactive values describing the current state.
| Name | Type | Description |
|---|---|---|
| isOpen | boolean | Whether the dropdown is open. |
| multiple | boolean | Whether multiple selection is enabled. |
| disabled | boolean | Whether the control is disabled. |
| readonly | boolean | Whether the control is read-only. |
| searchQuery | Q | undefined | Current search query. Q defaults to string. |
| filteredOptions | T[] | Options after applying the filter. |
| highlightedIndex | number | Index of the highlighted option (-1 when none). |
| alignmentOffset | number | Pixel offset used for alignment. |
| cssAnchorName | string | Unique CSS anchor-name for popover positioning. |
| popupStyle | HeadlessComboboxPopupStyle | Default popup positioning style — spread/merge onto the dropdown. |
| selectedCount | number | Number of selected options. |
| canSelectMore | boolean | False when at `maxLength` (multiple). |
| isSelected | (option: T) => boolean | Whether an option is selected. |
| valid | boolean | Whether the current selection passes validation. |
| errors | HeadlessComboboxErrorCode[] | Active validation errors ('required' | 'minlength' | 'maxlength'). |
| validationMessage | string | Human-readable message for the first error. |
ARIA prop bags
Spread onto elements with v-bind for accessibility.
| Name | Type | Description |
|---|---|---|
| triggerProps | HeadlessComboboxTriggerProps | Attributes + toggle/keydown handlers for the trigger (role=combobox, aria-expanded, aria-activedescendant, …). |
| inputProps | HeadlessComboboxInputProps | Attributes + input/keydown handlers for an in-popup search/filter input (role=searchbox, aria-activedescendant, …). |
| comboboxInputProps | HeadlessComboboxComboboxInputProps | Attributes + open/focus/input handlers for a typeahead input that is itself the combobox (role=combobox, aria-expanded, aria-autocomplete, …). |
| listboxProps | HeadlessComboboxListboxProps | Attributes for the listbox (role, aria-multiselectable). |
| getOptionProps | (option, index) => HeadlessComboboxOptionProps | Attributes + select/mouse/focus handlers for an option (role, aria-selected, aria-disabled, …). |
Actions
Methods to drive the combobox.
| Name | Type | Description |
|---|---|---|
| toggle | () => void | Toggle the dropdown open/closed. |
| open | () => void | Open the dropdown. |
| close | (returnFocus?: boolean) => void | Close the dropdown. |
| select | (option: T) => void | Select (single) or toggle (multiple) an option. |
| clear | () => void | Clear the selection (null or []). |
| setSearchQuery | (value: Q | undefined) => void | Update the search query. |
| setHighlightedIndex | (index: number) => void | Set the highlighted option — wire to hover to match keyboard. |
| focusInput | () => void | Focus the filter input (keeps focus while the popup stays open). |
| handleKeydown | (event: KeyboardEvent) => void | Keyboard navigation handler. |
Ref setters
Assign to elements with :ref to wire up focus and positioning.
| Name | Type | Description |
|---|---|---|
| setContainerRef | ref fn | The root container element. |
| setTriggerRef | ref fn | The trigger button element. |
| setDropdownRef | ref fn | The dropdown popup element. |
| setInputRef | ref fn | The search input element. |
| setListRef | ref fn | The options list element. |
| setOptionRef | (option, el) => void | Each option element. |