npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

combobox-headless

v0.1.0

Published

A framework-independent, headless combobox engine providing state management, keyboard navigation, and accessibility — zero dependencies, zero UI opinions.

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.

npm version bundle size license


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-combobox

Or 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      │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
  1. You call a method (e.g., setQuery('go'))
  2. The engine updates its internal state
  3. All subscribers are notified with the new state
  4. 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