Native-first, dependency-free custom selects with search, multi-select, a plugin API, and no framework lock-in.
The name is intentional. In the Unix tradition of less (which does more than more), worse-select does less than most custom select libraries — by design. It doesn't reimplement form state, validation, or change events. It enhances the browser instead of replacing it.
Most custom select libraries own the form control. They maintain their own value state, wire up their own change events, and require you to feed them data in a specific format. When something goes wrong, you're debugging their abstraction instead of your app.
worse-select keeps the native <select> as the source of truth. It hides the native element, renders a styled companion UI next to it, and syncs from the real control. Form submission, validation, disabled state, and change events all come from the browser — not from a reimplementation.
That means:
- Drop it into any form and it works with what the browser already does
- No framework adapter needed — it enhances a standard HTML element
- Integrates cleanly with Svelte, React, Vue, and plain HTML
- Dynamically added selects are handled with optional observer-based auto-mount
- Custom behavior lives in
data-*attributes, keeping the API close to standard HTML
- Native-first state model —
<select>stays canonical - Dependency-free
- Searchable option lists with match highlighting and screen reader announcements (matches are highlighted and scrolled into view; non-matching options stay visible to preserve context)
- Listbox mode via native
size - Multi-select via native
multiple - Type-ahead option find
- Placeholder option support via the conventional
<option value="" disabled>pattern - Dynamic DOM support with optional observer-based auto-mount
- Theming through CSS custom properties
- Full keyboard navigation with ARIA state management
- Plugin API for extending or replacing built-in behavior
- Cleanup API for SPA usage
npm install worse-selectimport { worseSelect } from 'worse-select';
worseSelect();With no arguments, worseSelect() scans document and enhances every native <select> it finds.
<select>
<option value="" disabled selected>Choose one</option>
<option value="ford">Ford</option>
<option value="honda">Honda</option>
<option value="toyota">Toyota</option>
</select>The placeholder text is shown in the button when the dropdown is closed and cleared when it opens.
<select data-searchable="true">
<option value="" disabled selected>Choose one</option>
<option value="ford">Ford</option>
<option value="honda">Honda</option>
<option value="toyota">Toyota</option>
</select><select size="6" data-searchable="true">
<option>One</option>
<option>Two</option>
<option>Three</option>
<option>Four</option>
<option>Five</option>
<option>Six</option>
</select><select multiple size="8" data-searchable="true">
<option>Maryland</option>
<option>Virginia</option>
<option>Pennsylvania</option>
<option>Delaware</option>
</select>Custom widget behavior is configured with data-* attributes on the native <select>.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-searchable |
true | false |
false |
Adds a search input above the options list |
data-dropdown-height-px |
number |
400 |
Sets the max height of the options scroller |
data-width |
string |
100% |
Overrides the rendered widget width |
data-height |
string |
32px |
Overrides the rendered widget height |
These stay native on purpose:
size— triggers listbox modemultiple— enables multi-selectdisabled— disables the control
That split keeps the API aligned with standard HTML instead of introducing parallel widget options.
import { worseSelect } from 'worse-select';
import type { Plugin, PluginContext } from 'worse-select';
worseSelect(root?: ParentNode, options?: { observe?: boolean; plugins?: Plugin[] }): () => voidEnhances native <select> elements under the given root. Safe to call multiple times — each select is mounted at most once.
worseSelect(); // scans document
worseSelect(someContainerElement); // scans a subtreeTo automatically enhance selects added after the initial call:
const cleanup = worseSelect(document, { observe: true });The returned cleanup function disconnects observers and destroys all mounted instances under the root. Useful for SPA route teardown.
The plugin API is how you extend worse-select with custom behavior. It is also how worse-select grows — the built-in search is itself a plugin, and new first-party features will be added the same way. That means the API gets the same level of care as everything else in the library.
A plugin is a factory function that returns a Plugin object:
import { worseSelect } from 'worse-select';
import type { Plugin, PluginContext } from 'worse-select';
function createMyPlugin(): Plugin {
return {
name: 'my-plugin',
init(context: PluginContext) {
// use context.on() so event listeners are removed automatically on destroy
context.on(context.headerElement, 'click', () => { /* ... */ });
},
onOpen() { /* dropdown opened */ },
onClose() { /* dropdown closed */ },
onSync() { /* native select state changed */ },
destroy() { /* instance torn down */ },
};
}
worseSelect(document, {
plugins: [createMyPlugin()]
});PluginContext gives access to the widget elements and a small utility API:
| Property / Method | Description |
|---|---|
selectElement |
The native <select> |
headerElement |
The trigger button |
optionsListElement |
The options scroller |
searchInputElement |
The search input, if data-searchable="true" |
setMessage(text) |
Posts a message to the visually-hidden live region for screen readers |
clearMessage() |
Clears the live region |
on(target, event, handler) |
Registers an event listener that is removed automatically when the instance is destroyed |
PluginContextexposes live DOM elements owned by worse-select. Attaching listeners viacontext.on(), reading state, and applying narrowly scoped classes or attributes is fine — removing or restructuring core elements can break the widget.
The built-in search highlights matching options client-side. To replace it with server-side filtering, provide a plugin named 'search' — worse-select skips the built-in when a plugin with that name is already registered.
Replacing native <option> elements triggers worse-select's mutation observer, which rebuilds the option list automatically.
function createRemoteSearchPlugin(url: string): Plugin {
return {
name: 'search', // overrides the built-in search plugin
init(context) {
const { searchInputElement, selectElement } = context;
if (!searchInputElement) return;
let debounce: ReturnType<typeof setTimeout>;
context.on(searchInputElement, 'input', (event) => {
const term = (event.target as HTMLInputElement).value.trim();
clearTimeout(debounce);
if (!term) {
selectElement.innerHTML = '';
context.clearMessage();
return;
}
debounce = setTimeout(async () => {
context.setMessage('Loading...');
const res = await fetch(`${url}?q=${encodeURIComponent(term)}`);
const items: { value: string; label: string }[] = await res.json();
selectElement.innerHTML = items
.map(({ value, label }) => `<option value="${value}">${label}</option>`)
.join('');
context.setMessage(items.length ? `${items.length} results` : 'No results found');
}, 300);
});
},
};
}
worseSelect(document.querySelector('#my-form')!, {
plugins: [createRemoteSearchPlugin('/api/search')]
});worse-select uses CSS custom properties for theming. Override only what you need.
:root {
--ws-border-color: #767676;
--ws-border-radius: 2px;
--ws-bg: #fff;
--ws-text-color: inherit;
--ws-disabled-bg: #f0f0f0;
--ws-disabled-text-color: #6d6d6d;
--ws-hover-bg: #f1f1f1;
--ws-active-bg: #eef4ff;
--ws-active-outline: #2563eb;
--ws-selected-bg: #d2e3fc;
--ws-selected-text-color: #174ea6;
--ws-focus-outline: #2563eb;
--ws-search-border-color: #b7b7b7;
--ws-divider-color: #d0d0d0;
--ws-highlight-bg: #fff3a3;
--ws-shadow: 0 4px 12px rgba(0, 0, 0, 0.16);
}Keyboard navigation and ARIA state management are built in:
- Full arrow key, Home/End, Page Up/Down navigation
role="listbox"androle="option"witharia-selected,aria-disabled, andaria-activedescendantaria-expandedon the trigger button- Screen reader announcements for search results via a visually-hidden live region
Custom select behavior can have browser- and assistive-technology-specific edge cases. Validate in your target environments before relying on it broadly.
- Does not support virtualization or full combobox-style widgets out of the box — async/remote search can be built with a custom plugin (see Plugins)
- Runtime changes to
sizeormultiplemay be better handled with teardown and remount if the control changes mode significantly - Designed to stay small and predictable — not every possible custom select feature is in scope
Suitable for early production use in applications that want a native-first custom select without a dependency-heavy abstraction layer.
- Native form state stays canonical
- Standard HTML attributes stay standard
- Custom behavior lives in
data-*attributes - Keep the code small
- Prefer predictable behavior over feature creep
LGPL-3.0-or-later