kselect.js
A modern, accessible select replacement — single file, no dependencies. Drop in two files and every <select> on your page gets live search, tag-style multi select, collapsible optgroups, and full keyboard navigation.
Installation
Download kselect.js and kselect.css, place them in your project, and add them to your page. There is no build step, no package manager, and no dependencies.
<link rel="stylesheet" href="kselect.css"> <script src="kselect.js"></script>
kselect also ships as a CommonJS module for use in Node.js-based build pipelines:
const Kselect = require('./kselect.js');
Quick start
Point Kselect.init at a CSS selector. The widget replaces the native <select> in place — your HTML, form submission, and any existing event listeners remain completely unchanged.
<select id="my-select"> <option value="js">JavaScript</option> <option value="py">Python</option> <option value="go">Go</option> </select> <script> const [ks] = Kselect.init('#my-select'); </script>
To initialise every <select> on the page at once:
Kselect.init('select'); // all selects Kselect.init('.enhanced'); // selects with a specific class
Kselect.init always returns an array of instances, even when only one element matched. Use array destructuring (const [ks] = …) or [0] to get a single instance.
Basic usage
Passing options
Options are passed as a plain object to the second argument of Kselect.init. All options are optional — the defaults work without any configuration.
Kselect.init('#my-select', { placeholder: 'Choose an option…', searchPlaceholder: 'Search…', noResultsText: 'No results found', maxHeight: 300, searchable: true, allowClear: true, closeOnSelect: true, });
Data attributes
Several options can also be set directly on the <select> element as data- attributes. The JavaScript option always takes precedence when both are present.
<select data-placeholder="Pick a country…" data-search-placeholder="Filter countries…" > … </select>
Retrieving an existing instance
const selectEl = document.getElementById('my-select'); const ks = Kselect.getInstance(selectEl);
Single select
Single select mode is used automatically when the <select> element does not have the multiple attribute. The dropdown closes after an option is picked (configurable via closeOnSelect).
<select id="country" data-placeholder="Choose a country…"> <option value=""></option> <option value="us">United States</option> <option value="gb">United Kingdom</option> <option value="ca">Canada</option> </select> <script> const [ks] = Kselect.init('#country'); // Read the current value ks.getValue(); // → 'us' (string, or null if nothing selected) </script>
To keep the dropdown open after a selection (useful inside modals or filter panels):
Kselect.init('#country', { closeOnSelect: false });
Multi select
Add the multiple attribute to your <select> to enable multi select mode. Selected values appear as removable tag pills inside the control. The dropdown stays open after each pick so users can select several options in a row.
<select id="fruits" multiple data-placeholder="Pick some fruits…"> <option value="apple">Apple</option> <option value="banana">Banana</option> <option value="cherry">Cherry</option> <option value="mango">Mango</option> </select> <script> const [ks] = Kselect.init('#fruits'); // Read all selected values ks.getValue(); // → ['apple', 'cherry'] (always an array in multi mode) // Set multiple values at once ks.setValue(['banana', 'mango']); </script>
Optgroups
Standard <optgroup> elements are supported natively. Group headers are rendered with a collapse/expand toggle. When a user types in the search box, any collapsed groups that contain matching options are expanded automatically.
<select id="languages" multiple> <optgroup label="Web"> <option value="js">JavaScript</option> <option value="ts">TypeScript</option> </optgroup> <optgroup label="Backend"> <option value="go">Go</option> <option value="rust">Rust</option> </optgroup> </select> <script> // Groups start expanded (default) Kselect.init('#languages'); // Groups start collapsed Kselect.init('#languages', { collapseGroups: true }); </script>
Select all
Multi select mode supports two "select all" helpers. Both can be used independently or together.
Global select all
Adds a "Select all" row at the top of the dropdown with a tri-state checkbox (checked / indeterminate / unchecked).
Kselect.init('#my-select', { selectAll: true, selectAllText: 'Select all', // optional label });
When selectAll: true is set, the search input moves from the control into the left side of the "Select all" row, forming a single compact bar. This keeps the dropdown header clean.
Per-group select all
Adds a select-all checkbox inside each optgroup header. Can be used alone or alongside the global selectAll option.
Kselect.init('#permissions', { selectAll: true, selectAllGroups: true, selectAllGroupText: 'Select all', // accessible label for group checkboxes });
Search
Live search filtering is on by default. As the user types, non-matching options are hidden and collapsed optgroups are automatically expanded if they contain a match.
To disable search entirely:
Kselect.init('#my-select', { searchable: false, noResultsText: 'No results found', // shown when search matches nothing searchPlaceholder: 'Type to filter…', });
Disabled states
Disable the whole control
Add the disabled attribute to the <select>, or call the API methods at any time:
<select id="my-select" disabled>…</select>
ks.disable(); ks.enable();
Disable individual options
Disabled options are rendered in the list but cannot be selected or focused by keyboard.
<select id="plan"> <option value="free">Free</option> <option value="pro" disabled>Pro (unavailable)</option> <option value="enterprise">Enterprise</option> </select>
Dynamic options
If you add, remove, or replace options on the underlying <select> after the widget has been initialised, tell kselect to rebuild by calling refresh() or dispatching kselect:sync:
const [ks] = Kselect.init('#my-select'); const selectEl = document.getElementById('my-select'); // Later, after an AJAX response adds new options: const opt = document.createElement('option'); opt.value = 'rust'; opt.text = 'Rust'; selectEl.appendChild(opt); // Option 1: call refresh() on the instance ks.refresh(); // Option 2: dispatch the sync event selectEl.dispatchEvent(new Event('kselect:sync'));
To replace all options at once (e.g. from a server response):
selectEl.innerHTML = ` <option value="a">Alpha</option> <option value="b">Beta</option> `; ks.refresh(); // rebuild from new DOM state
Mobile
kselect works on phones and tablets with no configuration. Two behaviours are built in:
- No keyboard pop-up on open. Tapping to open the dropdown does not auto-focus the search input, so the OS keyboard does not appear. Users can tap the search input explicitly if they want to filter.
- Viewport-aware positioning. If the keyboard does appear, the widget reads
window.visualViewportand keeps the dropdown anchored to the visible area rather than drifting under the keyboard.
Native picker on mobile
For forms where the native OS picker is preferred on touch devices, set nativeOnMobile: true. kselect detects coarse pointers via matchMedia('(pointer: coarse)') and leaves the native <select> in place.
Kselect.init('#my-select', { nativeOnMobile: true, }); // The full API still works in native mode: ks.getValue(); // reads from the native <select> ks.setValue('go'); // writes to the native <select> ks.disable(); // disables the native <select>
In native mode: search filtering, tag pills, checkbox indicators, optgroup collapse, and select-all rows are not available — those are features of the custom widget. The ks-native CSS class is added to the native <select> so you can apply consistent border/padding styling.
Touchscreen laptops (with both a trackpad and a finger) report a fine primary pointer, so they get the full custom widget. Only pure-touch devices fall through to the native picker.
Events
All events are fired on the original <select> element. Listen with addEventListener as you would for any native element event.
Native DOM event. Fires when the selection changes. Works with standard form libraries and frameworks.
Custom event. Fires at the same time as change. Useful when you want to distinguish kselect changes from other sources.
Fires when the dropdown opens.
Fires when the dropdown closes.
kselect:sync is a special inbound event — dispatch it on the <select> to tell the widget to re-read the DOM after you've made external changes to the options.
const [ks] = Kselect.init('#my-select'); const el = document.getElementById('my-select'); el.addEventListener('kselect:change', () => { console.log('Selected:', ks.getValue()); }); el.addEventListener('kselect:open', () => { console.log('Dropdown opened'); }); // Trigger a rebuild after external DOM changes: el.dispatchEvent(new Event('kselect:sync'));
Options reference
All options are passed as the second argument to Kselect.init(selector, options).
| Option | Type | Default | Description |
|---|---|---|---|
placeholder |
string | 'Select an option…' | Text shown when nothing is selected. Also readable from data-placeholder on the <select>. |
searchPlaceholder |
string | 'Search…' | Placeholder text inside the search input. Also readable from data-search-placeholder. |
noResultsText |
string | 'No results found' | Message shown in the dropdown when a search query matches no options. |
maxHeight |
number | 300 | Maximum height of the dropdown in pixels before it starts scrolling. |
searchable |
boolean | true | Show or hide the search input inside the dropdown. |
allowClear |
boolean | true | Show a clear (×) button that deselects everything when clicked. |
closeOnSelect |
boolean | true | Close the dropdown after selecting an option. Applies to single select mode only — multi select always stays open. |
collapseGroups |
boolean | false | Start all <optgroup> sections in a collapsed state. Users can expand them by clicking the header. |
selectAll |
boolean | false | Show a global "Select all" row at the top of the dropdown (multi select only). Displays a tri-state checkbox reflecting checked / indeterminate / unchecked state. |
selectAllText |
string | 'Select all' | Label for the global select-all row. |
selectAllGroups |
boolean | false | Show a select-all checkbox inside each optgroup header (multi select only). Can be used with or without the global selectAll option. |
selectAllGroupText |
string | 'Select all' | Accessible label (aria-label) for the per-group select-all checkbox buttons. |
nativeOnMobile |
boolean | false | On coarse-pointer devices (phones, tablets), leave the native <select> visible instead of building the custom widget. The full instance API continues to work. |
summarizeSelected |
'auto' | 'off' | number | 'auto' | Multi-select only. Controls when to collapse the row of tags into a "{n} selected" summary. 'auto' collapses as soon as the tags would wrap to a second line. 'off' always shows every tag. A number n collapses when the selected count exceeds n. |
summarizeSelectedText |
string | '{n} selected' | Template for the summary text shown when summarizeSelected collapses the tags. {n} is replaced with the count of selected items. |
Instance API
Every method is available on the instance returned by Kselect.init. The static helper Kselect.getInstance(el) retrieves an existing instance from a DOM element.
Returns the current selection. In single mode, returns a string (the selected value) or null if nothing is selected. In multi mode, always returns an array of strings (empty array if nothing is selected).
ks.getValue(); // single → 'go' or null ks.getValue(); // multi → ['js', 'py'] or []
Programmatically sets the current selection. Fires change and kselect:change exactly once — or not at all if the new selection is identical to the old one.
Pass a string for single mode, or an array for multi mode. Passing null, undefined, or an empty array is treated as a call to clear().
ks.setValue('go'); // single ks.setValue(['js', 'py', 'go']); // multi ks.setValue(null); // clears selection
Clears all selected values and shows the placeholder. Fires change and kselect:change only if something was actually selected beforehand.
ks.clear();
Opens the dropdown programmatically. Accepts an optional options object.
ks.open(); ks.open({ skipSearchFocus: true }); // open without focusing the search input ks.close();
Enable or disable the control. Mirrors the disabled property on the underlying <select>. A disabled control cannot be opened and is skipped in the tab order.
ks.disable(); ks.enable();
Rebuilds the dropdown option list from the current state of the underlying <select>. Use this after adding, removing, or replacing <option> or <optgroup> elements programmatically. Equivalent to dispatching kselect:sync.
// Add option to native select, then sync the widget const opt = document.createElement('option'); opt.value = 'rust'; opt.text = 'Rust'; selectEl.appendChild(opt); ks.refresh();
Removes the kselect widget from the DOM, restores the original <select> to its original visible state (preserving any inline display style it had), and removes all event listeners.
ks.destroy(); // The original <select> is now visible again.
CSS variables
Every visual property in kselect is expressed as a CSS custom property. Override any of them on :root, on a parent container, or directly on the .ks-wrapper to restyle the widget without touching the stylesheet.
Typography
| Variable | Default | Description |
|---|---|---|
--ks-font-family | 'Inter', system-ui, sans-serif | Font family used throughout the widget |
--ks-font-size | 0.9rem | Base font size |
--ks-line-height | 1.4 | Base line height |
Colors — surfaces & text
| Variable | Default | Description |
|---|---|---|
--ks-color-bg | #ffffff | Control & dropdown background |
--ks-color-border | #a0aec0 | Default border (3.0:1 on white — meets WCAG 1.4.11) |
--ks-color-border-focus | #6366f1 | Border & ring color when open or focused |
--ks-color-text | #111827 | Primary text color |
--ks-color-placeholder | #767676 | Placeholder text (4.54:1 on white — meets WCAG 1.4.3) |
--ks-color-disabled-bg | #f3f4f6 | Background when the control is disabled |
--ks-color-disabled-text | #9ca3af | Text color when disabled |
--ks-color-disabled-border | #e5e7eb | Border color when disabled |
--ks-color-search-bg | #f9fafb | Search input background |
Colors — options
| Variable | Default | Description |
|---|---|---|
--ks-color-option-hover | #f0f0ff | Option row hover background |
--ks-color-option-selected | #6366f1 | Selected option background in single mode |
--ks-color-option-selected-text | #ffffff | Selected option text color in single mode |
--ks-color-option-selected-bg | #eef2ff | Selected option background in multi mode (light tint) |
--ks-color-option-selected-fg | #4f46e5 | Selected option text & checkbox tick in multi mode |
Colors — groups, tags & controls
| Variable | Default | Description |
|---|---|---|
--ks-color-group-header | #f9fafb | Optgroup header background |
--ks-color-group-label | #6b7280 | Optgroup label text color |
--ks-color-tag-bg | #eef2ff | Tag (multi select pill) background |
--ks-color-tag-text | #4f46e5 | Tag text color |
--ks-color-tag-border | #c7d2fe | Tag border color |
--ks-color-tag-remove | #818cf8 | Tag remove button color |
--ks-color-tag-remove-hover | #4f46e5 | Tag remove button hover color |
--ks-color-clear | #9ca3af | Clear (×) button color |
--ks-color-clear-hover | #ef4444 | Clear button hover color |
--ks-color-arrow | #9ca3af | Dropdown arrow color |
--ks-color-arrow-open | #6366f1 | Dropdown arrow color when open |
--ks-color-checkbox-border | #d1d5db | Checkbox border color |
--ks-color-checkbox-checked | #6366f1 | Checkbox background when checked |
Shape, shadow & animation
| Variable | Default | Description |
|---|---|---|
--ks-radius-control | 8px | Border radius of the closed control |
--ks-radius-dropdown | 10px | Border radius of the dropdown |
--ks-radius-tag | 5px | Border radius of multi-select tag pills |
--ks-radius-checkbox | 4px | Border radius of checkboxes |
--ks-shadow-dropdown | (layered shadow) | Box shadow of the dropdown |
--ks-transition | 0.18s ease | Duration and easing for all transitions |
--ks-z-index | 9999 | Z-index of the floating dropdown |
Theming
Because kselect uses CSS custom properties for everything, a theme is just a block of variable overrides — no forking the stylesheet required.
Scoped theme on a wrapper element
Apply a theme to a specific select by setting variables on a parent container:
.my-green-theme { --ks-color-border-focus: #10b981; --ks-color-option-selected: #10b981; --ks-color-option-selected-bg: #ecfdf5; --ks-color-option-selected-fg: #059669; --ks-color-tag-bg: #ecfdf5; --ks-color-tag-text: #059669; --ks-color-tag-border: #a7f3d0; --ks-color-checkbox-checked: #10b981; --ks-color-arrow-open: #10b981; }
<div class="my-green-theme"> <select id="tags" multiple>…</select> </div>
Global theme via :root
To apply a theme to every kselect widget on the page, override variables on :root:
:root { --ks-color-border-focus: #0ea5e9; --ks-color-option-selected: #0ea5e9; --ks-radius-control: 4px; --ks-radius-dropdown: 4px; }
Dark theme example
.theme-dark { --ks-color-bg: #1e1e2e; --ks-color-border: #313244; --ks-color-border-focus: #cba6f7; --ks-color-text: #cdd6f4; --ks-color-placeholder: #6c7086; --ks-color-option-hover: #313244; --ks-color-option-selected: #cba6f7; --ks-color-option-selected-text:#1e1e2e; --ks-color-tag-bg: #313244; --ks-color-tag-text: #cba6f7; --ks-color-tag-border: #45475a; --ks-shadow-dropdown: 0 4px 20px rgba(0,0,0,.5); }
The example page ships with 20 ready-made themes in the themes/ folder. Each is a standalone CSS file with :root overrides you can drop straight into your project.
Browser support
kselect uses standard DOM APIs and CSS custom properties. It works in all modern browsers:
- Chrome / Edge 80+
- Firefox 75+
- Safari 13+
- Mobile Safari (iOS 13+)
- Chrome for Android
Internet Explorer is not supported.
kselect is fully compliant with WCAG 2.1 AA. It uses role="combobox", aria-expanded, aria-selected, aria-activedescendant, aria-live announcements for search results, and native <button> elements for group headers.