LLSelect - Low-Level Select

npm version

A low-level JavaScript library aims to be an alternative of <select>.

This is a low-level <select>-liked component library written in TypeScript. Focus on performance and flexibility. You can easily wrap and integrate into your existing UI library / framework / style.

Tip

Why not native <select>?

Native <select> was designed in the 1990s, and the lots of limitations have caused enormous traumatic pains to OCD developers and designers for over two decades:

Arrrrgh... Yet another select library? Why not existing select libraries? Are you too bored?

Yeeeee, it's just because all of the existing libraries are unable to satisfy my requirements, mainly on aspect of performance (initializing hundreds of instances), then flexibility, and explicitly.

At least as of May 2026, this situation was still not solved. The only way was implement one to fit my ideal.

Warning

I know Semantic Versioning, but the API of versions < v0.1.0 is unstable currently and may have breaking changes. I'm still trying to eat my own dog food in real-world projects and trying to make the API stable. Thanks for your understanding.

Contents

Features

Warning

Browser support floor: Firefox 78+, Chrome/Edge 87+, Safari 14.1+. No polyfills or legacy-browser workarounds are included.

Design principles

  1. Minimalist
    • No external JS / CSS dependency. Auditable.
    • Do only one thing: "a minimal replacement of <select>", not aimed to be an omnipotent monster.
  2. Performance
    • Create minimal elements on DOM when instantiating to optimize the page loading latency.
    • The DOM of popup and candidates are lazy-rendering, and remove unneeded element from DOM when unneeded to minimize memory usages.
    • Mutate minimal DOM if possible. Toggling a candidate in the popup list replaces only the affected candidate rows and refreshes the trigger, instead of rebuilding the whole list. (Features that change which rows are listed or add a summary row - hideChosenRows, chooseAllRow - rebuild or update those parts too.)
  3. Flexible
    • Highly customizable: HTML templates of select itself, popup, candidates list, candidate row, arrow icon, ...etc.
    • Easy to integrate into an existing project / library / style.
    • Settings configure one instance; subclassing extends the library.
  4. Explicit
    • Explicit is better than implicit - API names are long, but hold no surprise or ambiguity.
    • Consistent & comprehensible API naming convention, avoid user from guessing the meaning of APIs.
    • Single-select and multiple-select are separate classes, avoiding ambiguous / over-abstracted APIs (for example, select2 uses T[] adopted on single & multiple modes.).
    • Improves some UI/UX anti-patterns of the legacy <select> (e.g. aria-disabled instead of native disabled, so a disabled control still receives hover events and can show a "why is this disabled" tooltip).

Benchmark

Note

100 selects x 100 candidates

Library Build total (all widgets, ms) DOM nodes (all, resting) Teardown total (all widgets, ms)
Native <select> 30 10,200 3.30
llselect 12 900 0.50
Choices.js 171 20,900 6.60
Select2 137 10,900 10
Tom Select 45 900 1.20
Slim Select 79 11,000 5.20

1000 selects x 10 candidates

Library Build total (all widgets, ms) DOM nodes (all, resting) Teardown total (all widgets, ms)
Native <select> 33 12,000 5.40
llselect 30 9,000 4.30
Choices.js 869 29,000 30
Select2 485 19,000 41
Tom Select 466 9,000 23
Slim Select 151 20,000 25

Bundle size

Minified bytes as loaded by the benchmark page (llselect from the local dist/index.umd.js, the rest from the pinned CDN builds); not gzipped.

Library Version Minified
llselect 0.0.8 42.0 KB
Choices.js 11.1.0 73.6 KB
Select2 4.1.0-rc.0 71.4 KB
Tom Select 2.4.3 49.1 KB
Slim Select 2.10.0 86.1 KB
jQuery (required by Select2) 3.7.1 85.5 KB

Demo

Install

npm install @llselect/core

Published on npm as @llselect/core. For AngularJS 1.x there is @llselect/angularjs, a separate package with its own setup - see angularjs/README.md.

Quick start

import { LLSelectSingle, LLSelectMultiple } from '@llselect/core'
import '@llselect/core/themes/vanilla.css' // optional: any shipped theme, or bring your own CSS

const sel = new LLSelectSingle(document.querySelector('#mount'), {
  ariaLabel: 'Fruit', // accessible name (or ariaLabelledBy: id of your visible label) - always set one
  placeholder: 'Pick a fruit',
  onChange: (item, previousItem) => console.log(item),
})
sel.setItems(['Apple', 'Banana', 'Cherry'])

Multi select: new LLSelectMultiple(el, { ... }) - getChosenItems() / toggleItem() / triggerDisplay: 'tags' / chooseAllRow: true / hideChosenRows: true and friends.

Language packs (optional, tree-shakeable pure data):

import { ja, zhTW } from '@llselect/core/i18n'
const sel = new LLSelectSingle(el, { uiTranslationPack: zhTW })
sel.setUiTranslationPack(ja) // switch language at runtime - no rebuild, chosen state survives

CDN (no build tool)

Everything in dist/ is served by both CDNs; pin at least the major version (@0):

<!-- library: window.llselect -->
<script src="https://cdn.jsdelivr.net/npm/@llselect/core@0/dist/index.umd.js"></script>
<!-- or: https://unpkg.com/@llselect/core@0/dist/index.umd.js -->

<!-- language packs (optional): window.llselectI18n -->
<script src="https://cdn.jsdelivr.net/npm/@llselect/core@0/dist/i18n.umd.js"></script>

<!-- a theme (optional): vanilla / tailwind / bootstrap-3 / bootstrap-4 / bootstrap-5 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@llselect/core@0/dist/themes/vanilla.css">

The bare URLs https://cdn.jsdelivr.net/npm/@llselect/core and https://unpkg.com/@llselect/core resolve straight to the UMD (via the jsdelivr / unpkg package fields). Browse every published file: jsdelivr file tree or unpkg browser. ESM and CJS entries ship unminified for bundlers (which minify your app themselves); the UMD is minified, and every file carries a source map.

Limitation: What llselect deliberately decides not to do?

<form> integration

llselect's selection state lives in JS (getChosenItem() / getChosenItems() / onChange), not in a form control. That is already enough for most apps:

A bridge is needed only for classic full-page form submission, where the browser builds the payload and serializes native form controls only. Mirror the selection into <input type="hidden"> - hidden inputs are inert by spec (unfocusable, no tab stop, outside the accessibility tree, excluded from constraint validation), so the mirror cannot leak into a11y or focus order:

const hidden = document.querySelector('input[name="country"]') // <input type="hidden" name="country"> inside the form
const sel = new LLSelectSingle(mountEl, {
  ariaLabelledBy: 'country-label',
  itemToStringFn: (c) => c.name,
  // a form value is a string - map it yourself; itemToStringFn is display text ("Taiwan"), not a submit value ("TW")
  onChange: (c) => { hidden.value = c?.code ?? '' },
})
sel.setItems([{ code: 'TW', name: 'Taiwan' }, { code: 'JP', name: 'Japan' }])

Multi select submits one hidden input per chosen value, all with the same name (spell it countries[] if your backend is PHP / Rails; keep it bare for Go / Python):

const form = document.querySelector('form')
const sel = new LLSelectMultiple(mountEl, {
  ariaLabelledBy: 'countries-label',
  itemToStringFn: (c) => c.name,
  onChange: (chosen) => {
    form.querySelectorAll('input[name="countries[]"]').forEach((el) => { el.remove() })
    for (const c of chosen) {
      const hidden = document.createElement('input')
      hidden.type = 'hidden'
      hidden.name = 'countries[]'
      hidden.value = c.code
      form.append(hidden)
    }
  },
})

The mirror covers submission only. The rest of native form behavior stays yours to handle:

Why there is no built-in setting for this, and why the recipe uses hidden inputs rather than a hidden <select> mirror: docs/llm/DESIGN.md "<form> integration (ruled out of core)".

Capabilities overview

Capability Entry points
Search box + custom matching filterable (bool or predicate), filterFn
Typing to jump (prefix typeahead) always on while the filter is off - no setting; matches the text itemToStringFn returns; see Typing to jump
Accessible field naming (required) ariaLabel / ariaLabelledBy / labelEl (visible label element: name + label-click-to-focus)
Disabling - whole control / per item setDisabled(), focusableWhenDisabled, itemDisabledFn
Grouping (optgroup) itemToGroupKeyFn, groupKeyToStringFn, groupDisabledFn
Multiple selection LLSelectMultiple: toggleItem(), getChosenItems(), chooseAllRow, hideChosenRows, triggerDisplay: 'count' | 'tags', clearable
Popup width popupWidthPolicy: 'fit-content' | 'match-trigger' (default 'fit-content' - grows to content like a native select)
Rich rendering without subclassing createItemContentElFn, createTriggerContentElFn, createTagContentElFn, ...
i18n uiTranslationPack setting + setUiTranslationPack() runtime switch + @llselect/core/i18n packs (uiTranslationPackByLocale, keyed by BCP 47 tag), RTL inherited from dir
Lifecycle destroy() (required on unmount), rerender(), setItems()
Events onChange(current, previous), onOpen, onClose

Typing to jump (prefix typeahead)

While the filter is off, typing characters moves the focused option to the match, like a native <select>.

Full contracts: docs/llm/DESIGN.md (API / architecture) and docs/llm/A11Y.md (keyboard / focus / ARIA). The TypeScript declarations shipped in the package document every setting inline.

API reference

Every class, setting and type, generated with TypeDoc from the same TSDoc that ships in the package's declarations - so it cannot drift from the source:

For the AngularJS directives (ll-* attributes), see the attribute reference in angularjs/API.md.

Method-name grammar

Method names follow a strict grammar. Some notes maybe helpful if you need to customize it via subclass / settings:

Name shape DOM contact Meaning
create*El(...) none - detached Builds a new element and returns it. Never inserts it.
commit*ToDom(content) writes the DOM Takes the content as its param. Writes it into the DOM.
sync*ToDom() writes the DOM No params. Reads one this.* state field. Writes it to the DOM.
replace*ElInDom(...) writes the DOM Swaps one existing element for a fresh one. O(1).
render*() none - orchestrator Calls the methods above in the right order. Writes no DOM itself.

((...) means the params vary per method. () means always zero params: the method reads instance state instead.)

Customization: settings or subclassing?

Settings can only fill content inside the elements the library builds. Subclassing changes those elements themselves, including their ARIA.

A complete worked subclass, in TypeScript with typed subclass settings (the class's S generic param): the tree multiple select in demo section 14.2 (demo/subclass/tree-select.ts).

Settings (the common path - no subclass needed)

You want to customize Setting
Item display text itemToStringFn
Trigger content (e.g. tag chips) createTriggerContentElFn
Disable individual items itemDisabledFn
Search matching filterFn
Highlight filter matches in rows createItemContentElFn + the createHighlightedTextEl helper
Equality for object items compareFn
Dropdown arrow createTriggerArrowContentElFn
Events onChange, onOpen, onClose
const sel = new LLSelectSingle(el, {
  itemToStringFn: (u) => `#${u.id} ${u.name}`,
  itemDisabledFn: (u) => !u.active,
  onChange: (u) => console.log('chosen:', u),
})

Settings are frozen after construction. The library re-applies nothing on its own, so a live setting would need its own re-apply code.

Highlight what the filter matched

The exported helper createHighlightedTextEl(text, query) wraps every match of query in a <mark> element. Pair it with createItemContentElFn, which re-runs on every filter keystroke:

import { LLSelectSingle, createHighlightedTextEl } from '@llselect/core'

let sel
sel = new LLSelectSingle(el, {
  filterable: true,
  createItemContentElFn: (item) => createHighlightedTextEl(item, sel.getFilterQuery()),
})

Subclassing (extending the library)

Subclass only when settings cannot express it:

  1. A new select kind - new public API / state / interaction (e.g. a TreeSelect).
  2. A framework wrapper - e.g. class VueLLSelect extends LLSelectSingle for lifecycle glue (call destroy() on unmount). This is the main reason llselect is "low-level".
  3. Core behavior with no setting - e.g. replace onItemActivated semantics, or take full control of the item element via createItemEl (rich HTML, icons).

How the two layers coexist: every customization point is a protected method whose default reads its *Fn setting. Overriding the method replaces that default - your override wins, plain OO, no hidden precedence. Rationale: docs/llm/DESIGN.md.

Acknowledgments

LLM Disclosures

This project heavily relies on LLM agents. More than 99% of the working code was written directly by an LLM.

So you are just a fucking idiot vibe coder? What on Earth were you responsible for in this project, if LLM has done so much?

I try to provide usable software, but I still cannot provide any warranty.

Special Thanks

The development of llselect is influenced by the following FLOSS projects:

Origin of This Project

I have had the idea to implement this library at least since 2020, because I had enough of the terrible inflexibility of the HTML native <select>, but none of any existing libraries satisfies my requirements. Especially the performance issue when initializing a page containing hundreds of selects components.

But I clearly know that there are surprisingly lots of details in the behaviours of a select, and deeply know how time-costing to implementing such library, so I didn't try to write it.

In 2024 I tried to wrote some drafts for it, but I still had no time to implement it, so the drafts were abandoned.

Now, with Claude Code, I am trying to finish it. Even with LLM agent, this project still costs me about 3 months to release v0.0.1.

License

Copyright (c) 2024, 2026 kuanyui (ono ono)

MIT License. See LICENSE for the full text.