HeadlessCombobox

headless
github.com/pdanpdan/headless-components/packages/headless-combobox

An 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

NameTypeDescription
wrapper elementsetContainerRefWidget boundary for click-outside and focus. Optional, recommended.
trigger buttontriggerProps, setTriggerRefrole="combobox" — toggles the popup, keyboard navigation.
typeahead inputcomboboxInputProps, setTriggerRef, setInputRefThe input is the combobox: typing reopens and filters.
search input (in popup)inputProps, setInputRefrole="searchbox" — filters while the popup stays open.
popup elementpopupStyle, setDropdownRefPositioning; add popover="manual" to use the native Popover API.
list elementlistboxProps, setListRefrole="listbox" — the options container.
each option elementgetOptionProps(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

NameTypeDescription
Enter / Space / ArrowDown / ArrowUppopup closedOpen the dropdown.
Backspace / Deletepopup closed, or open without a filter inputRemove the last (or only) selected option. Keys aimed at a text input that still holds text keep their native behavior.
ArrowDown / ArrowUppopup openMove the highlight; wraps around and skips disabled options.
PageDown / PageUppopup openMove the highlight a full page (visible options), clamped at the ends.
Home / Endpopup openJump to the first/last selectable option (native caret behavior in inputs).
Enterpopup openSelect the highlighted option.
Spacepopup openDoes 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+Tabpopup openSkip the options list to the next focusable element; closes when focus leaves the widget (selectOnTab selects the highlighted option first).
Escapepopup openClose 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.

Framework (single, no filter)

Text options · single · internal state

An initial modelValue seeds the internal selection; with no update listener the component keeps it internally.

Framework (single, no v-model)

Text options · single · select on tab

Press Tab with an option highlighted: it is selected and the popup closes (selectOnTab).

Framework (single, select on tab)

Object options · single · with filter

Object options with a custom layout and a searchable input.

Assign user (single, with filter)

Object options · single · separate value

Options carry a label and a value; the model holds the value only.

Framework id (single, separate value)

Text options · multiple · with filter

Multiple selection capped with max; selecting toggles, dropdown stays open.

Frameworks (multiple, max 3)

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.

Frameworks (multiple, max 3)
Vue Svelte

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.

Frameworks (multiple, custom options)

Object options · multiple · validated

Multiple selection with required + min/max validation and a rendered message.

Reviewers (multiple, 2–4 required)

Select at least 2 options.

Select alignment

No search input; the dropdown aligns so the selected option overlays the trigger.

Assign user

Typeahead (editable combobox)

Canonical APG editable pattern: the text input itself is the combobox (role=combobox); type to filter.

Language (typeahead, editable)

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.

Topics (typeahead, removable chips in the field)
TypeScript

Composable · single · typeahead

Same headless behavior without the component: useHeadlessCombobox exposes state, ARIA bags, and actions directly — wire your own markup with plain refs.

Language (composable, typeahead)

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

Member (composable, programmatic)

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

NameTypeDefaultDescription
modelValueV | V[] | nullinternalSelected value(s) (v-model). Optional: without v-model the component keeps the selection internally.
optionsO[]List of options.
multiplebooleanfalseEnable multiple selection.
minLengthnumberMultiple: minimum number of selected options (validation).
maxLengthnumberMultiple: maximum selected. Blocks adding beyond it.
requiredbooleanfalseRequire a selection (single: a value; multiple: >= 1).
disabledbooleanfalseDisable the control: not focusable, cannot open or change.
readonlybooleanfalseRead-only: focusable, shows the value, but cannot open or change.
closeOnSelectboolean | null!multipleClose the dropdown after selecting.
closeOnClickOutsidebooleantrueClose when clicking outside the widget boundary.
clickOutsideFilter(target: EventTarget | null) => booleanReturn false to keep the dropdown open for a specific outside target.
selectOnTabbooleanfalseOn Tab, when focus leaves the widget: select the highlighted option and close the popup.
optionValue(option: O) => Voption itselfMaps an option to the value stored in modelValue / emitted on select.
optionLabel(option: O) => stringString(option)Maps an option to a string for filtering / rendering.
optionFilter(option: O, query: Q) => booleansubstringCustom filter function. Q defaults to string.
idstringuseId()Base id for accessibility attributes.
alignSelectedbooleanfalseAlign the dropdown so the selected option covers the trigger.
errorMessagesPartial<Record<HeadlessComboboxErrorCode, string>>Override default validation messages.

Emits

NameTypeDescription
update:modelValueV | V[] | nullEmitted when the selection changes.

Slot default

Renderless. Receives the scope below to build the entire UI.

State

Reactive values describing the current state.

NameTypeDescription
isOpenbooleanWhether the dropdown is open.
multiplebooleanWhether multiple selection is enabled.
disabledbooleanWhether the control is disabled.
readonlybooleanWhether the control is read-only.
searchQueryQ | undefinedCurrent search query. Q defaults to string.
filteredOptionsT[]Options after applying the filter.
highlightedIndexnumberIndex of the highlighted option (-1 when none).
alignmentOffsetnumberPixel offset used for alignment.
cssAnchorNamestringUnique CSS anchor-name for popover positioning.
popupStyleHeadlessComboboxPopupStyleDefault popup positioning style — spread/merge onto the dropdown.
selectedCountnumberNumber of selected options.
selectedListV[]The selected values (single mode: a single-element array).
canSelectMorebooleanFalse when at `maxLength` (multiple).
isSelected(option: T) => booleanWhether an option is selected.
validbooleanWhether the current selection passes validation.
errorsHeadlessComboboxErrorCode[]Active validation errors ('required' | 'minlength' | 'maxlength').
validationMessagestringHuman-readable message for the first error.

ARIA prop bags

Spread onto elements with v-bind for accessibility.

NameTypeDescription
triggerPropsHeadlessComboboxTriggerPropsAttributes + toggle/keydown handlers for the trigger (role=combobox, aria-expanded, aria-activedescendant, …).
inputPropsHeadlessComboboxInputPropsAttributes + input/keydown handlers for an in-popup search/filter input (role=searchbox, aria-activedescendant, …).
comboboxInputPropsHeadlessComboboxComboboxInputPropsAttributes + open/focus/input handlers for a typeahead input that is itself the combobox (role=combobox, aria-expanded, aria-autocomplete, …).
listboxPropsHeadlessComboboxListboxPropsAttributes for the listbox (role, aria-multiselectable).
getOptionProps(option, index) => HeadlessComboboxOptionPropsAttributes + select/mouse/focus handlers for an option (role, aria-selected, aria-disabled, …).

Actions

Methods to drive the combobox.

NameTypeDescription
toggle() => voidToggle the dropdown open/closed.
open() => voidOpen the dropdown.
close(returnFocus?: boolean) => voidClose the dropdown.
select(option: T) => voidSelect (single) or toggle (multiple) an option.
clear() => voidClear the selection (null or []).
setSearchQuery(value: Q | undefined) => voidUpdate the search query.
setHighlightedIndex(index: number) => voidSet the highlighted option — wire to hover to match keyboard.
focusInput() => voidFocus the filter input (keeps focus while the popup stays open).
handleKeydown(event: KeyboardEvent) => voidKeyboard navigation handler.

Ref setters

Assign to elements with :ref to wire up focus and positioning.

NameTypeDescription
setContainerRefref fnThe root container element.
setTriggerRefref fnThe trigger button element.
setDropdownRefref fnThe dropdown popup element.
setInputRefref fnThe search input element.
setListRefref fnThe options list element.
setOptionRef(option, el) => voidEach option element.