{"id":21625249,"url":"https://github.com/crvouga/headless-combobox","last_synced_at":"2025-08-19T08:32:27.932Z","repository":{"id":153803902,"uuid":"630679737","full_name":"crvouga/headless-combobox","owner":"crvouga","description":"⚡️Zero dependencies 🔌 Framework agnostic 💪 TypeScript 🧠 Headless Combobox","archived":false,"fork":false,"pushed_at":"2024-06-25T19:10:11.000Z","size":979,"stargazers_count":43,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2024-12-08T19:35:51.651Z","etag":null,"topics":["aria","autocomplete","combobox","framework-agnostic","functional-programming","headless-ui","typescript","zero-dependency"],"latest_commit_sha":null,"homepage":"https://headless-combobox.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/crvouga.png","metadata":{"files":{"readme":"readme.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-04-20T23:04:30.000Z","updated_at":"2024-06-25T19:10:14.000Z","dependencies_parsed_at":"2024-03-28T01:46:05.560Z","dependency_job_id":"73af5557-9af3-4c13-8977-f07cc43f3795","html_url":"https://github.com/crvouga/headless-combobox","commit_stats":null,"previous_names":["crvouga/headless-autocomplete"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/crvouga%2Fheadless-combobox","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/crvouga%2Fheadless-combobox/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/crvouga%2Fheadless-combobox/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/crvouga%2Fheadless-combobox/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/crvouga","download_url":"https://codeload.github.com/crvouga/headless-combobox/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":230340130,"owners_count":18211162,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aria","autocomplete","combobox","framework-agnostic","functional-programming","headless-ui","typescript","zero-dependency"],"created_at":"2024-11-25T01:08:33.338Z","updated_at":"2025-08-19T08:32:27.920Z","avatar_url":"https://github.com/crvouga.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# headless-combobox\n\n![demo](https://github.com/crvouga/headless-combobox/raw/main/demo.gif)\n\n## ⚠️ WORK IN PROGRESS\n\nI'm comfortable using this in my projects but use at your own risk!\n\nThe public API may be unstable.\n\nLet me know if you find any issues.\n\n## Pros\n\n- 🧠 Headless. Bring your own styles.\n- 🔌 Framework agnostic. Bring your own framework.\n- ⚡️ Zero dependencies\n- ♿️ [WAI ARIA Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) support\n- 🧺 Multi Select supported\n- 🥚 Select Only supported\n- 💪 Written in TypeScript\n- 🌳 Simple pure functional [Elm](https://elm-lang.org/)-like API\n- 💼 Works anywhere JavaScript works.\n  - React Native\n  - Vanilla JS \u0026 HTML\n  - Vue\n  - Node.js\n  - Redux (Since the API is just pure functions)\n  - Any JS framework\n\n## Cons\n\n- 🧠 Headless. You do have to write your own styles.\n- 🔌 Framework agnostic. You do have to write error prone adapter code.\n- 🌳 [Elm](https://elm-lang.org/)-like API. People may hate that.\n- 📚 Missing good documentation. The only way to learn this library is through the examples.\n\n## Good use cases are\n\n- You need a custom looking combobox\n- You're working in a legacy framework\n- You're working in a framework with a small ecosystem\n- You're working in a framework that always has breaking changes\n- You hate learning how to override styles in combobox libraries\n\n## Demos\n\n- [Svelte Demo](https://svelte.headlesscombobox.chrisvouga.dev/)\n\n## Links\n\n- [bundlephobia](https://bundlephobia.com/package/headless-combobox)\n- [API Reference](https://headlesscombobox.chrisvouga.dev/)\n- [Github](https://github.com/crvouga/headless-combobox)\n- [NPM](https://www.npmjs.com/package/headless-combobox)\n\n## Installation\n\n### NPM\n\n```shell\nnpm install headless-combobox\n```\n\n### Yarn\n\n```shell\nyarn add headless-combobox\n```\n\n### PNPM\n\n```shell\npnpm install headless-combobox\n```\n\n## Complementary Libraries\n\n- [match-sorter](https://github.com/kentcdodds/match-sorter) for filtering items\n- [floating-ui](https://floating-ui.com/) for rendering the drop down.\n\n## Credit\n\nThis library is steals from these libraries:\n\n- [MUI's Autocomplete](https://mui.com/material-ui/react-autocomplete/#multiple-values)\n- [Headless UI's Combobox](https://headlessui.com/react/combobox)\n\n## Usage\n\n### Svelte Single Select Example\n\n```svelte\n\u003cscript lang=\"ts\"\u003e\n  import * as Combobox from \"./src\";\n\n  /*\n\n\n  Step 0: Have some data to display\n\n\n  */\n\n  type Item = { id: number; label: string };\n  const fruits = [\n    { id: 0, label: \"pear\" },\n    { id: 1, label: \"apple\" },\n    { id: 2, label: \"banana\" },\n    { id: 3, label: \"orange\" },\n    { id: 4, label: \"strawberry\" },\n    { id: 5, label: \"kiwi\" },\n    { id: 6, label: \"mango\" },\n    { id: 7, label: \"pineapple\" },\n    { id: 8, label: \"watermelon\" },\n    { id: 9, label: \"grape\" },\n  ];\n\n  let items: { [itemId: string]: HTMLElement } = {};\n  let input: HTMLInputElement | null = null;\n\n  /*\n\n\n  Step 1: Init the config\n\n\n  */\n\n  const config = Combobox.initConfig\u003cItem\u003e({\n    toItemId: (item) =\u003e item.id,\n    toItemInputValue: (item) =\u003e item.label,\n  });\n\n  /*\n\n\n  Step 2: Init the state\n\n\n  */\n\n  let model = Combobox.init(config, {\n    allItems: fruits,\n    inputMode: {\n      type: \"search-mode\",\n      inputValue: \"\",\n    },\n    selectMode: {\n      type: \"single-select\",\n    },\n  });\n\n  /*\n\n\n  Step 3: Write some glue code\n\n\n  */\n\n  const dispatch = (msg: Combobox.Msg\u003cItem\u003e | null) =\u003e {\n    if (!msg) {\n      return;\n    }\n\n    const output = Combobox.update(config, { msg, model });\n\n    console.log(model.type, msg.type, output.model);\n\n    model = output.model;\n\n    Combobox.handleEffects(output, {\n      focusInput: () =\u003e {\n        input?.focus();\n      },\n      focusSelectedItem: () =\u003e {},\n      scrollItemIntoView: (item) =\u003e {\n        items[item.id]?.scrollIntoView({ block: \"nearest\" });\n      },\n    });\n\n    // useful for emitting changed events to parent components\n    Combobox.handleEvents(output, {\n      onInputValueChanged() {\n        console.log(\"onInputValueChanged\");\n      },\n      onSelectedItemsChanged() {\n        console.log(\"onSelectedItemsChanged\");\n      },\n    });\n  };\n\n  const onKeydown = (event: KeyboardEvent) =\u003e {\n    const msg = Combobox.keyToMsg\u003cItem\u003e(event.key);\n    if (msg.shouldPreventDefault) {\n      event.preventDefault();\n    }\n    dispatch(msg);\n  };\n\n  /*\n\n\n  Step 4: Wire up to the UI\n\n  ⚠️ This is the error prone part\n\n  */\n\n  $: state = Combobox.toState(config, model);\n\u003c/script\u003e\n\n\u003cdiv class=\"container\"\u003e\n  \u003clabel\n    class=\"label\"\n    {...state.aria.inputLabel}\n    for={state.aria.inputLabel.for}\n  \u003e\n    Fruit Single Select\n  \u003c/label\u003e\n  \u003cp {...state.aria.helperText}\u003e{Combobox.ariaContentDefaults.helperText}\u003c/p\u003e\n\n  \u003cbutton on:click={() =\u003e dispatch({ type: \"pressed-unselect-all-button\" })}\u003e\n    Clear\n  \u003c/button\u003e\n\n  \u003cdiv class=\"input-container\"\u003e\n    \u003cinput\n      {...state.aria.input}\n      class=\"input\"\n      value={state.inputValue}\n      bind:this={input}\n      on:input={(event) =\u003e\n        dispatch({\n          type: \"inputted-value\",\n          inputValue: event.currentTarget.value,\n        })}\n      on:focus={() =\u003e dispatch({ type: \"focused-input\" })}\n      on:blur={() =\u003e dispatch({ type: \"blurred-input\" })}\n      on:mousedown={() =\u003e dispatch({ type: \"pressed-input\" })}\n      on:keydown={onKeydown}\n    /\u003e\n    \u003cul\n      {...state.aria.itemList}\n      class=\"suggestions\"\n      class:hide={!state.isOpened}\n    \u003e\n      {#if state.renderItems.length === 0}\n        \u003cli\u003eNo results\u003c/li\u003e\n      {/if}\n      {#each state.renderItems as item, index}\n        \u003cli\n          {...item.aria}\n          bind:this={items[item.item.id]}\n          on:mousemove={() =\u003e dispatch({ type: \"hovered-over-item\", index })}\n          on:mousedown|preventDefault={() =\u003e\n            /* Make sure it's a mousedown event instead of click event */\n            dispatch({ type: \"pressed-item\", item: item.item })}\n          on:focus={() =\u003e dispatch({ type: \"hovered-over-item\", index })}\n          class=\"option\"\n          class:highlighted={item.status === \"highlighted\"}\n          class:selected={item.status === \"selected\"}\n          class:selected-and-highlighted={item.status ===\n            \"selected-and-highlighted\"}\n        \u003e\n          {item.inputValue}\n        \u003c/li\u003e\n      {/each}\n    \u003c/ul\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n\n\u003c!--\n\n\n  We get to use our own styles 🎉\n\n\n --\u003e\n\u003cstyle\u003e\n  .container {\n    width: 100%;\n    max-width: 300px;\n  }\n\n  .input-container {\n    position: relative;\n  }\n  .label {\n    position: relative;\n    display: block;\n    width: 100%;\n  }\n\n  .hide {\n    display: none;\n  }\n  .input {\n    width: 100%;\n    padding: 0.5rem;\n    font-size: large;\n    box-sizing: border-box;\n    border: 1px solid #ccc;\n  }\n  .suggestions {\n    position: absolute;\n    top: 100%;\n    left: 0;\n    right: 0;\n    z-index: 1;\n    width: 100%;\n    max-height: 300px;\n    overflow: scroll;\n    border: 1px solid #ccc;\n    width: 100%;\n    max-width: 100%;\n    margin: 0;\n    padding: 0;\n    background: #efefef;\n    font-size: large;\n  }\n\n  @media (prefers-color-scheme: dark) {\n    .suggestions {\n      background: #121212;\n    }\n  }\n\n  @media (prefers-color-scheme: dark) {\n    .highlighted {\n      background-color: #eee;\n      color: black;\n    }\n  }\n\n  .option {\n    display: block;\n    cursor: pointer;\n    list-style: none;\n    width: 100%;\n    margin: 0;\n    padding: 0;\n  }\n  .highlighted {\n    background-color: #333;\n    color: white;\n  }\n  .selected {\n    background-color: blue;\n    color: #fff;\n  }\n  .selected-and-highlighted {\n    background-color: lightblue;\n  }\n\u003c/style\u003e\n\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcrvouga%2Fheadless-combobox","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcrvouga%2Fheadless-combobox","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcrvouga%2Fheadless-combobox/lists"}