Documentation

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.

Vanilla JS Zero dependencies WCAG 2.1 AA Mobile ready ~40 CSS variables

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.

html
<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:

js
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.

html
<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:

js
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.

js
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.

html
<select
  data-placeholder="Pick a country…"
  data-search-placeholder="Filter countries…"
></select>

Retrieving an existing instance

js
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).

html
<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):

js
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.

html
<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.

html
<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).

js
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.

js
Kselect.init('#permissions', {
  selectAll:          true,
  selectAllGroups:    true,
  selectAllGroupText: 'Select all', // accessible label for group checkboxes
});

Disabled states

Disable the whole control

Add the disabled attribute to the <select>, or call the API methods at any time:

html
<select id="my-select" disabled></select>
js
ks.disable();
ks.enable();

Disable individual options

Disabled options are rendered in the list but cannot be selected or focused by keyboard.

html
<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:

js
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):

js
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.visualViewport and 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.

js
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.

change

Native DOM event. Fires when the selection changes. Works with standard form libraries and frameworks.

kselect:change

Custom event. Fires at the same time as change. Useful when you want to distinguish kselect changes from other sources.

kselect:open

Fires when the dropdown opens.

kselect:close

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.

js
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.

getValue () string | string[] | null

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).

js
ks.getValue(); // single → 'go'  or null
ks.getValue(); // multi  → ['js', 'py']  or []
setValue (value) void

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().

js
ks.setValue('go');                   // single
ks.setValue(['js', 'py', 'go']);    // multi
ks.setValue(null);                   // clears selection
clear () void

Clears all selected values and shows the placeholder. Fires change and kselect:change only if something was actually selected beforehand.

js
ks.clear();
open ( [options] ) void

Opens the dropdown programmatically. Accepts an optional options object.

js
ks.open();
ks.open({ skipSearchFocus: true }); // open without focusing the search input
ks.close();
enable ()  /  disable () void

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.

js
ks.disable();
ks.enable();
refresh () void

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.

js
// 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();
destroy () void

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.

js
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

VariableDefaultDescription
--ks-font-family'Inter', system-ui, sans-serifFont family used throughout the widget
--ks-font-size0.9remBase font size
--ks-line-height1.4Base line height

Colors — surfaces & text

VariableDefaultDescription
--ks-color-bg#ffffffControl & dropdown background
--ks-color-border#a0aec0Default border (3.0:1 on white — meets WCAG 1.4.11)
--ks-color-border-focus#6366f1Border & ring color when open or focused
--ks-color-text#111827Primary text color
--ks-color-placeholder#767676Placeholder text (4.54:1 on white — meets WCAG 1.4.3)
--ks-color-disabled-bg#f3f4f6Background when the control is disabled
--ks-color-disabled-text#9ca3afText color when disabled
--ks-color-disabled-border#e5e7ebBorder color when disabled
--ks-color-search-bg#f9fafbSearch input background

Colors — options

VariableDefaultDescription
--ks-color-option-hover#f0f0ffOption row hover background
--ks-color-option-selected#6366f1Selected option background in single mode
--ks-color-option-selected-text#ffffffSelected option text color in single mode
--ks-color-option-selected-bg#eef2ffSelected option background in multi mode (light tint)
--ks-color-option-selected-fg#4f46e5Selected option text & checkbox tick in multi mode

Colors — groups, tags & controls

VariableDefaultDescription
--ks-color-group-header#f9fafbOptgroup header background
--ks-color-group-label#6b7280Optgroup label text color
--ks-color-tag-bg#eef2ffTag (multi select pill) background
--ks-color-tag-text#4f46e5Tag text color
--ks-color-tag-border#c7d2feTag border color
--ks-color-tag-remove#818cf8Tag remove button color
--ks-color-tag-remove-hover#4f46e5Tag remove button hover color
--ks-color-clear#9ca3afClear (×) button color
--ks-color-clear-hover#ef4444Clear button hover color
--ks-color-arrow#9ca3afDropdown arrow color
--ks-color-arrow-open#6366f1Dropdown arrow color when open
--ks-color-checkbox-border#d1d5dbCheckbox border color
--ks-color-checkbox-checked#6366f1Checkbox background when checked

Shape, shadow & animation

VariableDefaultDescription
--ks-radius-control8pxBorder radius of the closed control
--ks-radius-dropdown10pxBorder radius of the dropdown
--ks-radius-tag5pxBorder radius of multi-select tag pills
--ks-radius-checkbox4pxBorder radius of checkboxes
--ks-shadow-dropdown(layered shadow)Box shadow of the dropdown
--ks-transition0.18s easeDuration and easing for all transitions
--ks-z-index9999Z-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:

css
.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;
}
html
<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:

css
:root {
  --ks-color-border-focus:    #0ea5e9;
  --ks-color-option-selected: #0ea5e9;
  --ks-radius-control:        4px;
  --ks-radius-dropdown:       4px;
}

Dark theme example

css
.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.