combobox-headless
v0.1.0
Published
A framework-independent, headless combobox engine providing state management, keyboard navigation, and accessibility — zero dependencies, zero UI opinions.
Maintainers
Readme
headless-combobox
A tiny, framework-independent combobox engine for JavaScript.
Zero dependencies. Zero UI opinions. Full control.
You handle the markup and styling — headless-combobox handles the state, filtering, and logic.
Why?
Most combobox libraries are tied to a specific framework (React, Vue, Angular). If your project uses vanilla JS, Web Components, Svelte, Alpine.js, or anything else — you're stuck building one from scratch.
headless-combobox gives you:
- ✅ State management (open, close, query, selection)
- ✅ Built-in filtering (case-insensitive, out of the box)
- ✅ Reactive updates via subscribe/notify pattern
- ✅ Works with any rendering approach — DOM, canvas, terminal, anything
- ✅ < 1 kB gzipped
Installation
npm install headless-comboboxOr with a CDN (UMD build):
<script src="https://unpkg.com/headless-combobox"></script>
<script>
const { createCombobox } = HeadlessCombobox;
</script>Quick Start
import { createCombobox } from 'headless-combobox';
const combobox = createCombobox({
items: [
{ label: 'JavaScript', value: 'js' },
{ label: 'TypeScript', value: 'ts' },
{ label: 'Python', value: 'py' },
{ label: 'Go', value: 'go' },
],
onSelect: (item) => {
console.log('Selected:', item);
},
});
// Subscribe to state changes
combobox.subscribe((state) => {
console.log(state);
// → { isOpen, query, activeIndex, selectedItem, filteredItems }
});
// Simulate user typing
combobox.setQuery('ja');
// → filteredItems: [{ label: 'JavaScript', value: 'js' }]
// Select the first filtered result
combobox.selectItem(0);
// → selectedItem: { label: 'JavaScript', value: 'js' }Full Vanilla JS Example
Here's how to wire headless-combobox to real DOM elements:
<label for="combo-input">Pick a language</label>
<input id="combo-input" type="text" placeholder="Type to search..." />
<ul id="combo-menu"></ul>import { createCombobox } from 'headless-combobox';
const input = document.getElementById('combo-input');
const menu = document.getElementById('combo-menu');
const combobox = createCombobox({
items: [
{ label: 'JavaScript', value: 'js' },
{ label: 'TypeScript', value: 'ts' },
{ label: 'Python', value: 'py' },
],
onSelect: (item) => {
console.log('Selected:', item.label);
},
});
// --- Render the dropdown whenever state changes ---
combobox.subscribe((state) => {
// Sync input value
input.value = state.query;
// Show/hide the menu
menu.style.display = state.isOpen ? 'block' : 'none';
// Render filtered items
menu.innerHTML = '';
state.filteredItems.forEach((item, index) => {
const li = document.createElement('li');
li.textContent = item.label;
// Highlight the active item
if (index === state.activeIndex) {
li.style.background = '#e0e7ff';
}
// Mark the selected item
if (state.selectedItem?.value === item.value) {
li.style.fontWeight = 'bold';
}
// Click to select
li.addEventListener('click', () => combobox.selectItem(index));
menu.appendChild(li);
});
});
// --- Wire up input events ---
input.addEventListener('input', (e) => combobox.setQuery(e.target.value));
input.addEventListener('focus', () => combobox.openMenu());
// Close on click outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#combo-input, #combo-menu')) {
combobox.closeMenu();
}
});API Reference
createCombobox(options)
Creates a new combobox instance. Returns an object with methods to control the combobox.
Options
| Option | Type | Default | Description |
|---|---|---|---|
| items | Array | [] | The list of items. Can be strings or objects with a label property. |
| onSelect | Function | () => {} | Callback fired when an item is selected. Receives the selected item. |
Items Format
Items can be strings or objects:
// Simple strings
createCombobox({ items: ['Apple', 'Banana', 'Cherry'] });
// Objects with label (and any other properties you need)
createCombobox({
items: [
{ label: 'Apple', id: 1, category: 'fruit' },
{ label: 'Banana', id: 2, category: 'fruit' },
],
});When items are objects, the label property is used for display and filtering.
Returned Methods
getState()
Returns the current state of the combobox. Does not trigger a re-render.
const state = combobox.getState();State shape:
| Property | Type | Description |
|---|---|---|
| isOpen | boolean | Whether the dropdown menu is open |
| query | string | The current search/filter text |
| activeIndex | number | Index of the highlighted item in filteredItems (-1 if none) |
| selectedItem | any \| null | The currently selected item, or null |
| filteredItems | Array | Items matching the current query |
setQuery(query)
Updates the search query. Automatically opens the menu and resets the active index to 0.
combobox.setQuery('type');
// → opens menu, filters to items matching "type"| Parameter | Type | Description |
|---|---|---|
| query | string | The new search text |
selectItem(index)
Selects an item by its index in the filtered list. Closes the menu and fires the onSelect callback.
combobox.selectItem(0); // Select the first filtered item| Parameter | Type | Description |
|---|---|---|
| index | number | Index in filteredItems (not the original items array) |
openMenu()
Opens the dropdown menu. Resets activeIndex to 0. No-op if already open.
combobox.openMenu();closeMenu()
Closes the dropdown menu. Resets activeIndex to -1. No-op if already closed.
combobox.closeMenu();toggleMenu()
Toggles the dropdown menu open/closed.
combobox.toggleMenu();subscribe(callback)
Registers a callback that fires whenever the state changes. Returns an unsubscribe function.
const unsubscribe = combobox.subscribe((state) => {
console.log('State changed:', state);
});
// Later, stop listening:
unsubscribe();| Parameter | Type | Description |
|---|---|---|
| callback | Function | Receives the new state object on every change |
You can add multiple subscribers. Each one receives the full state object.
How It Works
headless-combobox uses a closure-based state machine with a pub/sub notification system:
┌─────────────────────────────────────────┐
│ createCombobox() │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Private State │ │
│ │ isOpen, query, activeIndex, │ │
│ │ selectedItem, filteredItems │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ Public Methods │ │
│ │ setQuery() → updates state │ │
│ │ selectItem() → updates state │ │
│ │ openMenu() → updates state │ │
│ │ closeMenu() → updates state │ │
│ │ toggleMenu() → updates state │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ notify() on every change │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ Subscribers │ │
│ │ callback(state) → your UI │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘- You call a method (e.g.,
setQuery('go')) - The engine updates its internal state
- All subscribers are notified with the new state
- You re-render your UI based on the new state
Framework Integration
Since headless-combobox is just a plain JS state machine, it integrates with any framework:
React
import { useState, useEffect, useMemo } from 'react';
import { createCombobox } from 'headless-combobox';
function useCombobox(options) {
const engine = useMemo(() => createCombobox(options), []);
const [state, setState] = useState(engine.getState());
useEffect(() => {
const unsubscribe = engine.subscribe(setState);
return () => unsubscribe();
}, [engine]);
return { ...state, ...engine };
}Vue (Composition API)
import { reactive, onMounted, onUnmounted } from 'vue';
import { createCombobox } from 'headless-combobox';
export function useCombobox(options) {
const engine = createCombobox(options);
const state = reactive(engine.getState());
let unsubscribe;
onMounted(() => {
unsubscribe = engine.subscribe((newState) => {
Object.assign(state, newState);
});
});
onUnmounted(() => unsubscribe?.());
return { state, ...engine };
}Svelte
<script>
import { createCombobox } from 'headless-combobox';
const engine = createCombobox({ items: ['A', 'B', 'C'] });
let state = engine.getState();
engine.subscribe((s) => (state = s));
</script>Bundle Size
| Format | Size | Gzipped | |---|---|---| | ESM | 0.93 kB | 0.48 kB | | UMD | 0.91 kB | 0.52 kB |
License
MIT © chandrabhan_kumar
