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. The same state machine is also available as the useHeadlessCombobox composable. Supports single or multiple selection (with min/max counts) and reactive validation.
Installation
pnpm add @pdanpdan/headless-combobox
Structure
The component renders nothing on its own — you build the markup and bind the slot scope to it. The markup follows the ARIA combobox pattern: a trigger that opens a popup listing the options, optionally with a search input. This is where each slot prop is designed to be used:
Trigger + popup — a button opens a list below it; most examples use this layout.
HeadlessCombobox (default slot scope)
└─ div :ref="setContainerRef" widget boundary (optional)
├─ button :ref="setTriggerRef" the trigger
│ │ v-bind="triggerProps"
│ └─ selected label
├─ ul :ref="setDropdownRef" the popup
│ │ v-bind="listboxProps" :style="popupStyle"
│ │ popover="manual" optional: native Popover API
│ └─ li v-for="(option, i) in filteredOptions"
│ └─ button :ref="setOptionRef(option, el)" one option
│ v-bind="getOptionProps(option, i)"
└─ input :ref="setInputRef" search input (optional)
v-bind="inputProps"Typeahead — the text input itself is the combobox; typing reopens the popup and filters.
HeadlessCombobox (default slot scope)
└─ div :ref="setContainerRef" widget boundary (optional)
├─ input :ref="setTriggerRef" the combobox input
│ │ v-bind="comboboxInputProps"
└─ ul :ref="setDropdownRef" the popup
│ v-bind="listboxProps" :style="popupStyle"
│ popover="manual" optional: native Popover API
└─ li v-for="(option, i) in filteredOptions"
└─ button :ref="setOptionRef(option, el)" one option
v-bind="getOptionProps(option, i)"Markup → slot props
| Name | Type | Description |
|---|---|---|
| wrapper element | setContainerRef | Widget boundary for click-outside and focus. Optional, recommended. |
| trigger button | triggerProps, setTriggerRef | role="combobox" — toggles the popup, keyboard navigation. |
| typeahead input | comboboxInputProps, setTriggerRef, setInputRef | The input is the combobox: typing reopens and filters. |
| search input (in popup) | inputProps, setInputRef | role="searchbox" — filters while the popup stays open. |
| popup element | popupStyle, setDropdownRef | Positioning; add popover="manual" to use the native Popover API. |
| list element | listboxProps, setListRef | role="listbox" — the options container. |
| each option element | getOptionProps(option, index), setOptionRef(option, el) | Option semantics + select/highlight handlers. |
Ref setters are wired with :ref and are optional; setOptionRef enables alignSelected and scroll-into-view, and setContainerRef extends the click-outside boundary (e.g. to external chips).
The popup is positioned with CSS anchor positioning: the trigger needs :style="{ anchorName: cssAnchorName }", and the popup gets :style="popupStyle" plus popover="manual" — the component then drives it with the native Popover API (top-layer rendering, no v-if). Animate the open state with CSS transitions on :popover-open, like the examples do.
A simple popup style: hidden unless open, with a slide + fade entry animation (the @starting-style block plays the entry; the overlay/display transitions make the top-layer appearance and the display: none switch animatable):
.cbx-popup {
inset: auto;
position-try: flip-block;
opacity: 0;
margin-block-start: 0;
@media (prefers-reduced-motion: no-preference) {
transition:
opacity 0.15s ease,
margin-block-start 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
}
&:not(:popover-open) {
display: none;
}
&:popover-open {
opacity: 1;
margin-block-start: 0.5rem;
@starting-style {
opacity: 0;
margin-block-start: 0;
}
}
}
Keyboard
| Name | Type | Description |
|---|---|---|
| Enter / Space / ArrowDown / ArrowUp | popup closed | Open the dropdown. |
| Backspace / Delete | popup closed, or open without a filter input | Remove the last (or only) selected option. Keys aimed at a text input that still holds text keep their native behavior. |
| ArrowDown / ArrowUp | popup open | Move the highlight; wraps around and skips disabled options. |
| PageDown / PageUp | popup open | Move the highlight a full page (visible options), clamped at the ends. |
| Home / End | popup open | Jump to the first/last selectable option (native caret behavior in inputs). |
| Enter | popup open | Select the highlighted option. |
| Space | popup open | Does nothing on the trigger (the native click that would toggle the popup closed is suppressed); options and text entries keep their native behavior. |
| Tab / Shift+Tab | popup open | Skip the options list to the next focusable element; closes when focus leaves the widget (selectOnTab selects the highlighted option first). |
| Escape | popup open | Close the popup and return focus to the trigger. |
Mouse interactions: clicking the trigger toggles the dropdown, hovering or focusing an option highlights it (the same highlight the keyboard drives), clicking an option selects it, and clicking outside the widget closes the dropdown. Escape works from anywhere inside the widget.
Examples
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.
Text options · single · internal state
An initial modelValue seeds the internal selection; with no update listener the component keeps it internally.
Text options · single · select on tab
Press Tab with an option highlighted: it is selected and the popup closes (selectOnTab).
Object options · single · with filter
Object options with a custom layout and a searchable input.
Object options · single · separate value
Options carry a label and a value; the model holds the value only.
Text options · multiple · with filter
Multiple selection capped with max; selecting toggles, dropdown stays open.
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.
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.
Object options · multiple · validated
Multiple selection with required + min/max validation and a rendered message.
Select alignment
No search input; the dropdown aligns so the selected option overlays the trigger.
Typeahead (editable combobox)
Canonical APG editable pattern: the text input itself is the combobox (role=combobox); type to filter.
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.
Composable · single · typeahead
Same headless behavior without the component: useHeadlessCombobox exposes state, ARIA bags, and actions directly — wire your own markup with plain refs.
Composable · programmatic control
The composable escapes the slot: open, close, select, and clear are plain functions, callable from anywhere — here from buttons outside the widget. clickOutsideFilter keeps the popup open while the control panel is used.
Drive it from anywhere:
Selected: none · popup closed
Composable
Two APIs, one state machine: the component renders its default slot with the scope below, and useHeadlessCombobox exposes the exact same state, ARIA prop bags, actions, and ref setters directly — the component is a thin wrapper around it. Use the component for slot-driven templates; use the composable for programmatic control, wrapper components, or non-slot layouts. Call it from setup().
Props accept plain values, refs, or a getter — pass your modelValue ref directly (unwrapped and tracked internally). The second argument receives every update:modelValue payload; write it back to your state.
<script setup lang="ts">
import { useHeadlessCombobox } from '@pdanpdan/headless-combobox';
import { ref } from 'vue';
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
];
const selected = ref<User | null>(null);
const {
isOpen,
filteredOptions,
comboboxInputProps,
listboxProps,
getOptionProps,
select,
setContainerRef,
setTriggerRef,
setInputRef,
setDropdownRef,
setListRef,
setOptionRef,
} = useHeadlessCombobox<User>(
{
modelValue: selected,
options: users,
optionLabel: (user: User) => user.name,
},
(value) => {
selected.value = value as User | null;
},
);
</script>
<template>
<div :ref="setContainerRef">
<input
:ref="(el) => { setTriggerRef(el); setInputRef(el); }"
v-bind="comboboxInputProps"
aria-label="Search users"
type="text"
/>
<ul
v-if="isOpen"
:ref="(el) => { setDropdownRef(el); setListRef(el); }"
v-bind="listboxProps"
>
<li
v-for="(user, index) in filteredOptions"
:key="user.id"
>
<button
:ref="(el) => setOptionRef(user, el)"
type="button"
v-bind="getOptionProps(user, index)"
@click="select(user)"
>
{{ user.name }}
</button>
</li>
</ul>
</div>
</template>
The Composable · single · typeahead and Composable · programmatic control examples above show full usage. The returned HeadlessComboboxScope<O, V, Q> mirrors the slot scope — refs for state, plain functions for actions and ref setters.
API
Props
| Name | Type | Default | Description |
|---|---|---|---|
| modelValue | V | V[] | null | internal | Selected value(s) (v-model). Optional: without v-model the component keeps the selection internally. |
| options | O[] | — | 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. |
| closeOnClickOutside | boolean | true | Close when clicking outside the widget boundary. |
| clickOutsideFilter | (target: EventTarget | null) => boolean | — | Return false to keep the dropdown open for a specific outside target. |
| selectOnTab | boolean | false | On Tab, when focus leaves the widget: select the highlighted option and close the popup. |
| optionValue | (option: O) => V | option itself | Maps an option to the value stored in modelValue / emitted on select. |
| optionLabel | (option: O) => string | String(option) | Maps an option to a string for filtering / rendering. |
| optionFilter | (option: O, 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 | V | V[] | 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. |
| selectedList | V[] | The selected values (single mode: a single-element array). |
| 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. |