A lightweight, themeable multi-select web component with typeahead search, RTL support, rich content, and full keyboard navigation.
@keenmate/web-multiselect is a custom element (<web-multiselect>) that turns a list of options into a searchable, themeable multi-select dropdown. Framework-agnostic — works in React, Vue, Svelte, Blazor, plain HTML.
Reads --base-* variables from the page if @keenmate/theme-designer is present, falls back to sensible OS-aware defaults otherwise, and ships with first-class dark-mode and per-instance theming.
Headline features:
- Declarative
<option>/<optgroup>markup — no JavaScript required for simple cases. - Virtual scrolling for 10,000+ option datasets (25× faster opening, 99.8% memory reduction).
- Filter or navigate search modes; async / hybrid search.
- Five badge display modes (pills, count, compact, partial, none) with positioning on any side.
- Full keyboard navigation, RTL language support, badge tooltips.
- Custom rendering callbacks for options, badges, and group headers.
- Form integration via standard hidden inputs (FormData-compatible).
- Panel sizing — dropdown and popover are independently sizable via CSS variables — The options dropdown and selected-items popover no longer inherit the input's width.
--ms-dropdown-width(defaults to the live input width) and--ms-selected-popover-width(intrinsic 32rem) drive them, alongside the existing max-height variables. Set them at app level (web-multiselect { --ms-dropdown-width: 60rem }) or override a single instance with the newdropdown-width/selected-popover-widthattributes, which write those variables inline on the element. This also fixes a latent bug where--ms-selected-popover-widthwas dead (an internal width-sync always overrode it), so the popover now honours its 32rem default. See section 14 ofexamples-tree.html. - Tree rendering — the option render callback receives full tree context —
renderOptionContentCallback(item, ctx)gainsisTreeNode,isBranch,isLeaf,childCount,level,depth,path,isSelectable, and the cascadeisIndeterminatetristate. Rendering a child-count badge on branches or branch/leaf-specific markup is now a one-liner, without re-deriving the hierarchy yourself. All fields are optional (flat options reportisTreeNode: false), so existing callbacks are untouched. Demoed as the new "Custom Node Rendering" section. - Cascade counter — the
[N]chip counts what you actually picked — In cascade mode the counter used to show the emitted value count, which balloons undercascade-select-policy="leaves"/"all"(one branch click can emit five values) — jarring next to a couple of rolled-up badges. It now counts the rolled-up minimal cover — the branches you selected — regardless of emit policy, and gained a hover tooltip listing those items. Under the defaultrolled-uppolicy nothing changes. - Theming cleanup — dead input size-variant surface removed from the bundle — The unused
.ms__input--xs/sm/lg/xlpreset classes and their--ms-input-size-*variable chain were never wired to anything (nosizeattribute toggles them), so they've been commented out — kept for a possible future preset API but no longer shipped as dead CSS (the compiled stylesheet shrank ~2 kB). Input sizing still works through--ms-remfor proportional global scale or the individual--ms-input-*variables for targeted overrides. - Fixes — badge hover and live cascade switching — Badge hover no longer washes the chip to white (the hover background fell through to the white input background; it now deepens the accent tint, dark-mode aware). And switching
checkbox-modeorcascade-select-policylive rebuilds the cascade index and silently re-projects the current selection, so badges, form value, and checkboxes all reflect the new mode instantly.
- Selection —
setSelected(values, { notify: true })announces programmatic changes —setSelected()stays silent by default (restoring saved state, cascade/dependent resets, and server-authoritative corrections must not re-firechange, or they trip "the user changed it" handlers and can bounce in a feedback loop), but the new{ notify: true }option fires a single aggregatechange— no per-itemselect/deselectflood — for when a programmatic change is a deliberate user gesture, e.g. a custom action button that sets the selection and should reach the same listeners a manual pick does. Threaded through both the internal picker and the web component'ssetSelected. - Cascade mode — Select All now honours the value policy — the built-in
select-allaction added every selectable node's value directly, bypassing the cascade projection, so withcheckbox-mode="cascade"+cascade-select-policy="rolled-up"it emitted every node instead of the rolled-up roots (unlike a click, which rolls up).selectAll()is now cascade-aware — it fills the checked-atom set from the visible nodes and projects through the active policy via a sharedcommitCascadeAtomshelper — so Select All emits the same shape a click does. Shown in the new "Action Buttons" section ofexamples-tree.html. - Options no longer text-select on click-drag — clicking a row, or dragging across the dropdown, used to highlight the labels like selectable text, which reads as broken for a pure selection gesture (most visible on the denser tree rows). Options now set
user-select: none(with the-webkit-prefix) on.ms__option. Purely presentational — no API or behaviour change.
⚠️ Security notice: This component intentionally allows raw HTML in rendering callbacks to give developers full control over content display. If you display user-generated content, you must sanitize it yourself. See docs/examples.md → HTML Injection (XSS) notice for the complete list of affected callbacks.
- 🚀 Live demo
- 📘 Usage / API reference — attributes, properties, methods, events.
- 🎨 Theming —
--ms-*variables, dark mode, cascade layers, Theme Designer integration. - 📚 Examples / cookbook — rich content, async search, virtual scroll, custom rendering, forms.
- ♿ Accessibility — keyboard model, ARIA labels, focus behavior.
npm install @keenmate/web-multiselectDeclarative — no JavaScript required:
<script type="module">
import '@keenmate/web-multiselect';
</script>
<web-multiselect placeholder="Pick a country">
<option value="cz">Czech Republic</option>
<option value="sk">Slovakia</option>
<option value="at">Austria</option>
</web-multiselect>Programmatic — dynamic data + events:
<web-multiselect id="picker" search-placeholder="Search…"></web-multiselect>
<script type="module">
import '@keenmate/web-multiselect';
const picker = document.getElementById('picker');
picker.options = [
{ value: 'js', label: 'JavaScript', icon: '🟨' },
{ value: 'ts', label: 'TypeScript', icon: '🔷' },
{ value: 'py', label: 'Python', icon: '🐍' }
];
picker.addEventListener('change', (e) => {
console.log('Selected:', e.detail.selectedValues);
});
</script>See docs/usage.md for the full API and docs/examples.md for advanced patterns (async data, virtual scrolling, custom rendering, form integration).
Modern evergreen browsers — anything with native customElements, Shadow DOM, and CSS @layer support:
- Chrome / Edge 99+
- Firefox 97+
- Safari 15.4+
No polyfills are shipped. SSR-safe: the module imports without crashing in Node, but renders only after hydration in the browser.
# Install dependencies
npm install
# Start dev server (HMR)
npm run dev
# Build for production
npm run build
# Create package tarball
npm run package
# Run tests
npm run test:unit # Vitest (happy-dom) — fast logic checks
npm run test:e2e # Playwright (browser) — interaction/visual
npm test # bothFollows the BlissFramework four-layer web-component layout:
| Layer | File | Role |
|---|---|---|
| Element | src/web-component.ts |
MultiSelectElement — custom-element I/O wrapper, ATTRIBUTE_TABLE-driven |
| Logic | src/multiselect.ts |
WebMultiSelect<T> — framework-agnostic core |
| Service | src/tooltip.ts, src/virtual-scroll.ts |
single-purpose helpers (Tooltip, VirtualScroll) |
| Side | src/types.ts, src/logger.ts, src/vendor/ |
types, logging, vendored deps |
Two deviations from the canonical shape, both intentional:
MultiSelectElement extends BaseElement, notHTMLElementdirectly.BaseElementis a localconstresolving toHTMLElementin the browser and to a stub class under SSR (typeof HTMLElement === 'undefined'), so importing the module in Node doesn't throw. A literalgrep "extends HTMLElement"structure check will not match here by design.src/vite-env.d.tsis a standard Vite ambient-types file, not part of the four-layer model.
MIT — see LICENSE.
Follows the BlissFramework component guidelines for structure, theming, color-scheme, and accessibility. Per-check verifications run via /validate-web-component.
Created by Keenmate as part of the Pure Admin design system.