A zero-dependency replacement for <select>
with search, tags and groups — the native element stays in the DOM, so your
forms never notice. Everything below is live.
<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/select/select.js"></script>
Progressive enhancement of a native select — keyboard, typeahead and theming included.
new Select('#fruit')
Case- and diacritic-insensitive filter (try “zur”), plus a ✕ to reset.
new Select('#city', {
searchable: true,
clearable: true
})
Removable chips in the control, checkmarks in the list. Backspace removes the last tag.
new Select('#toppings', {
searchable: true,
maxItems: 3
})
<optgroup> becomes labelled groups; disabled
options are skipped by the keyboard.
<optgroup label="Strings">… <option disabled>Harp</option>
No native select needed — any container plus options;
a hidden field carries name for forms.
new Select('#size', {
name: 'size',
options: ['S', 'M', 'L', 'XL'],
onChange: v => show(v)
})
The hidden native select submits as usual — no JS on the read side.
<form> <select name="plan">…</select> <select name="addons" multiple>…</select> </form>
Just data attributes — activated on load.
<select data-vsel data-vsel-searchable data-vsel-placeholder="Search countries…">
Toggles the native disabled too, so submit semantics
stay native.
sel.disable() / sel.enable()
The complete surface of select.js v1.0.0 — constructor, options,
methods, statics, events, declarative attributes, theming and accessibility. Everything below is
taken from the source; where the README summarises, the source is authoritative.
new Select(target, options?) // → Select instance
Select.create(target, options?) // identical alias
target is a CSS selector string or a DOM element. If no element matches, the
constructor throws. Creating a second instance on the same element destroys the previous one
first. Without a DOM (SSR), the constructor returns an inert instance whose public methods
all no-op.
There are two modes, decided by the target's tag:
<select> mode — progressive enhancementOptions, selection, multiple and disabled are read from the
element. It stays in the DOM (hidden, aria-hidden, marked
data-vsel-bound) and every change is mirrored back, so forms, validation and
existing change listeners keep working. A leading empty-value option is treated
as the placeholder: its label is adopted (when you didn't pass your own
placeholder) and clearing re-selects it.
<select id="fruit" name="fruit">
<option value="">Choose a fruit…</option>
<option value="apple">Apple</option>
</select>
<script>new Select('#fruit', { searchable: true })</script>
Any non-<select> element becomes the host; options come from the
options array. Pass name and the widget maintains a hidden
<input type="hidden"> (single) or hidden <select multiple>
(multiple) inside itself, so it submits like any form field.
<div id="size"></div>
<script>
new Select('#size', {
name: 'size',
options: ['S', 'M', 'L', { value: 'xl', label: 'XL', disabled: true }],
value: 'M',
onChange: function (v) { console.log(v) }
})
</script>
Every option, with the exact default from the Select.defaults object in the
source. Any default can be changed globally, once: Select.defaults.placeholder = '…'.
| Name | Type | Default | Description |
|---|---|---|---|
options |
array | null |
Container mode only. Array of option objects (shape below) and/or plain strings —
'a' is shorthand for { value: 'a', label: 'a' }. In native
mode the options come from the element's <option>s instead. |
value |
string | array | null |
Initial value; a string (single) or array of strings (multiple). Values with no
matching option are dropped; duplicates are deduped; in single mode only the first
survives; maxItems is enforced. |
multiple |
boolean | false |
Multiple selection with removable tags. In native mode, when not passed explicitly,
inherited from the native element's multiple. |
searchable |
boolean | false |
Adds a filter input at the top of the panel. Matching is case- and
diacritic-insensitive ("zur" finds "Zürich"). |
clearable |
boolean | false |
Shows a ✕ button in the control that resets the selection (to the placeholder option, when the native select has one). |
placeholder |
string | 'Select…' |
Text shown when nothing is selected. In native single mode, a leading empty-value
<option>'s label overrides this default (only when you left
placeholder untouched). |
name |
string | null |
Container mode only: form field name for the hidden carrier —
<input type="hidden"> (single) or hidden
<select multiple> (multiple). |
maxItems |
number | null |
Cap for multiple selection. Extra initial values are trimmed; once the cap is reached, further picks are ignored until a tag is removed. |
noResultsText |
string | 'No results' |
Empty-state text shown in the panel when the search filter matches nothing. |
disabled |
boolean | false |
Start disabled. In native mode, when not passed explicitly, inherited from the
native element's disabled. Toggle later with
enable() / disable(). |
theme |
string | 'auto' |
'auto' | 'light' | 'dark'. Auto follows
<html data-theme> / data-bs-theme /
.dark/.light class, then
prefers-color-scheme — re-resolved live on changes. |
styles |
boolean | true |
false = headless: no CSS is injected; you style the
.vsel-* markup yourself (see Theming). |
position |
string | 'auto' |
'auto' | 'below' | 'above'. Auto opens below
and flips above when there is no room; the panel is clamped to the viewport
horizontally and repositioned on scroll/resize. |
labels |
object | { remove: 'Remove', clear: 'Clear selection', search: 'Search', options: 'Options' } |
UI / ARIA strings, merged shallowly over the defaults: remove (tag ✕
label prefix), clear (clear button), search (search field
label + placeholder), options (listbox label). |
onChange |
function | null |
fn(value, select) — called after the selection changes (see
Events). |
onOpen |
function | null |
fn(select) — called after the panel opens. |
onClose |
function | null |
fn(select) — called after the panel closes. |
Entries of the options array (and of data-vsel-options JSON) are
normalized to this shape; a bare string 'a' becomes
{ value: 'a', label: 'a' }. Values are always coerced to strings.
| Property | Type | Default | Description |
|---|---|---|---|
value | string | falls back to label |
Submitted / returned value. |
label | string | falls back to value |
Visible text. Rendered with textContent unless html is set. |
disabled | boolean | false |
Not selectable; skipped by keyboard navigation and typeahead. |
group | string | null |
Options sharing a group render under a labelled group heading
(native <optgroup> maps to this). |
html | boolean | false |
Opt-in to render label as trusted HTML instead of text. Only use with
markup you control. |
All methods return the instance (chainable) except getValue(). On an inert
SSR instance every method is a safe no-op.
| Method | Returns | Description |
|---|---|---|
open() | this |
Opens the panel (no-op if already open or disabled). Renders and positions the
list, focuses the search field when searchable, then fires
onOpen / select:open. Inside an open
<dialog> the panel joins the dialog in the top layer. |
close(refocus?) | this |
Closes the panel and fires onClose / select:close.
By default focus returns to the control when it was inside the panel; pass
false to leave focus alone (used internally when focus is moving on),
true to always refocus the control. |
toggle() | this |
Opens when closed, closes (refocusing the control) when open. |
getValue() | string | null or string[] |
Current value: a string or null in single mode, a copied array in
multiple mode. |
setValue(value, config?) | this |
Sets the value (string, array, or null/[] to clear).
Unknown values are dropped, maxItems enforced. Fires
onChange / select:change and the native
change; pass { silent: true } to skip all of them. |
enable() | this |
Re-enables the control (restores tabindex, removes
aria-disabled) and clears the native element's disabled. |
disable() | this |
Closes the panel, disables the control and sets the native element's
disabled, so submit semantics stay native. |
refresh() | this |
Re-reads the options: from the native <select>'s current markup
(mutate its <option>s, then call this), or in container mode by
re-normalizing opts.options (mutate sel.opts.options first).
The current selection is pruned to values that still exist. |
destroy() | this |
Tears everything down — removes the widget and panel, unbinds all listeners and
theme watchers — and restores the native <select> exactly as it
was. |
Also readable on the instance: sel.el (target element),
sel.native (the native <select>, or null in
container mode), sel.isOpen, and sel.opts (resolved options).
| Static | Description |
|---|---|
Select.create(target, options?) |
Same as new Select(target, options); returns the instance. |
Select.get(target) |
Returns the live instance bound to an element (selector or element), or
null. |
Select.autoInit(root?) |
Initializes every [data-vsel] element under root
(default: document) that doesn't already have an instance; returns the
array of created instances. One bad element logs an error without aborting the rest. |
Select.defaults |
The shared defaults object — mutate it to change any default for all future
instances, e.g. Select.defaults.styles = false. |
Select.version |
Library version string ('1.0.0'). |
Select.salt |
CSS isolation token, default 'vc1'. Set your own token (e.g.
Select.salt = 'acme') or false to disable salting —
before the first instance is created. |
Select.css |
The full embedded stylesheet as a string, rendered with the current salt — a
starting point for headless styling (also shipped as
dist/select.css). |
Select.rootClass |
'vsel' — the widget's root class name. |
Select.themeVars |
Map of shared theme tokens to CSS custom properties:
{ accent: '--vsel-accent', radius: '--vsel-radius', font: '--vsel-font' }
(used by the VC core's VC.config() bridge). |
Select.varScopes |
['.vsel', '.vsel[data-theme=dark]'] — the selectors where theme
variables are defined (deliberately unsalted so page overrides work). |
Select.displayName |
'Select' — family convergence contract; when the VC core is present
the component registers itself via VC.register('select', Select). |
Each lifecycle moment is exposed twice: as a constructor callback and as a bubbling
CustomEvent dispatched on the target element (the native
<select> or the container), so you can also listen with delegation.
| DOM event | Callback | event.detail | When |
|---|---|---|---|
select:change |
onChange(value, select) |
{ value, select } |
After the selection changes — via click, keyboard, tag removal, clear button or
setValue(). value matches getValue(). Skipped
entirely with setValue(v, { silent: true }). |
select:open |
onOpen(select) |
{ select } |
After the panel opens. |
select:close |
onClose(select) |
{ select } |
After the panel closes. |
In native mode, every non-silent change also dispatches a bubbling native
change event on the hidden <select>, so pre-existing form
listeners keep working untouched.
document.getElementById('city').addEventListener('select:change', function (e) {
console.log(e.detail.value, e.detail.select)
})
Every [data-vsel] element is auto-initialized on
DOMContentLoaded (or immediately if the script loads later). Call
Select.autoInit(root) yourself for content added afterwards — already-bound
elements are skipped. For boolean attributes, presence means true; the values
"false" and "0" mean false.
| Attribute | Maps to | Notes |
|---|---|---|
data-vsel | — | Marks the element for auto-init. Bound native selects also get a
data-vsel-bound attribute. |
data-vsel-searchable | searchable |
Boolean. |
data-vsel-clearable | clearable |
Boolean. |
data-vsel-multiple | multiple |
Boolean; for container mode (a native select uses its own
multiple attribute). |
data-vsel-placeholder="…" | placeholder |
String. |
data-vsel-name="…" | name |
String; container mode. |
data-vsel-value="a" / "a,b" | value |
A value containing a comma becomes an array (multiple). |
data-vsel-max-items="3" | maxItems |
Coerced to a number. |
data-vsel-no-results="…" | noResultsText |
String. |
data-vsel-options='["a","b"]' | options |
Parsed as JSON (strings or full option objects); a plain comma list like
"a,b,c" works as shorthand. |
data-vsel-theme="dark" | theme |
auto | light | dark. |
data-vsel-position="above" | position |
auto | below | above. |
data-vsel-styles="false" | styles |
Boolean; "false"/"0" for headless. |
<select data-vsel data-vsel-searchable data-vsel-clearable>…</select>
<div data-vsel data-vsel-name="size" data-vsel-placeholder="Pick a size…"
data-vsel-options='["S","M","L","XL"]'></div>
Light/dark is automatic (see the theme option). All colors are CSS custom
properties defined on .vsel — override them from your own stylesheet; the
injected sheet is inserted before the page's first stylesheet precisely so your
overrides win. The dark theme redefines them on .vsel[data-theme=dark].
| Property | Default (light) | Default (dark) | Purpose |
|---|---|---|---|
--vsel-accent | #5b5bd6 | #7b7bea |
Focus ring, checkmarks, selected option, open border, tags. |
--vsel-on-accent | #ffffff | #131418 |
Text/icon color on accent backgrounds (tag ✕ hover). |
--vsel-bg | #ffffff | #1b1d24 |
Control and panel background. |
--vsel-surface | #f2f2f5 | #272a33 |
Active option highlight, search field, disabled control background. |
--vsel-text | #1c1d21 | #e9eaf0 |
Main text color. |
--vsel-muted | #72747e | #989aa6 |
Placeholder, icons, group labels, empty-state text. |
--vsel-faint | #e7e7ec | #31343f |
Borders (control, panel, search field). |
--vsel-accent-soft | rgba(91,91,214,.13) |
derived | Focus-ring glow and tag background. Where color-mix() is supported it
is derived from --vsel-accent automatically
(color-mix(in srgb, var(--vsel-accent) 14%, transparent)). |
--vsel-shadow |
0 10px 28px rgba(24,25,32,.14), 0 2px 8px rgba(24,25,32,.08) |
0 10px 28px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.35) |
Panel drop shadow. |
--vsel-radius | 12px | same | Panel border radius. |
--vsel-font |
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif |
same | Font family for the control and panel. |
.vsel { --vsel-accent: #b45309; --vsel-radius: 8px; }
CSS isolation: the control and panel render as
class="vsel vc1" and all structural rules ship salted
(.vsel.vc1 .vsel-option { … }), so host-page design systems can't accidentally
override the widget — while the --vsel-* variable definitions stay unsalted so
the overrides above keep working. Change the token with Select.salt = 'acme' or
disable with Select.salt = false, before the first instance.
Headless: set styles: false per instance (or
Select.defaults.styles = false globally, or data-vsel-styles="false")
and no CSS is injected — you keep the full behavior (selection, search, keyboard, ARIA, form
sync) and style the .vsel-* markup contract yourself. The stock stylesheet is
available as a starting point via Select.css (string) or
dist/select.css (file). With the VC core loaded,
VC.config({ accent: '#b45309' }) themes selects and every other family component
in one call.
| Key | Action |
|---|---|
Enter / Space / ↓ / ↑ / Alt+↓ |
Open the list (when closed). |
↓ / ↑ |
Move the active option — skips disabled options, wraps at the ends. |
Home / End |
First / last enabled option (moves the caret instead while there is text in the search field). |
Enter |
Select the active option (closes the panel in single mode; toggles in multiple). |
Space |
Outside the search field: select the active option, or extend a pending typeahead buffer. |
Esc |
Close, return focus to the control. |
Tab |
Close and move focus on naturally. |
a, b, c… |
Typeahead by first letters when not searchable (also opens the list
when closed). The buffer resets after 500 ms; repeating a letter cycles through
matches. Case- and diacritic-insensitive. |
Backspace |
Multiple mode with an empty search field: remove the last tag (works while closed, too). |
role="combobox" with
aria-haspopup="listbox", aria-expanded,
aria-controls and aria-activedescendant; the panel is a
role="listbox" of role="option" items with
aria-selected / aria-disabled; groups are
role="group" with aria-label; the list gets
aria-multiselectable="true" in multiple mode.role="combobox" with aria-autocomplete="list") while
↑/↓ move the active option — screen readers follow via
aria-activedescendant.<label for="…"> pointing at
the native select; in container mode set aria-label /
aria-labelledby on the container or use the labels option.tabindex="-1" so the widget is one tab stop;
Backspace removes tags from the keyboard.textContent; html: true per
option is an explicit opt-in for trusted markup.:focus-visible outlines only; prefers-reduced-motion
disables all transitions and animations.styles: false injects nothing — selection, search,
keyboard, ARIA, form sync and the stable .vsel-* class hooks all remain; you
bring your own CSS. The demos above have already injected the kit stylesheet into this
page, so a headless instance here would still match those rules — each demo below
therefore runs inside its own <iframe>, a genuinely clean document.
Raw browser rendering, zero CSS — open it: search, arrow keys, typeahead and the native-select sync all still work.
new Select('#city', {
searchable: true, clearable: true,
styles: false // no CSS injected
})
The same hooks mapped to a rose checkout look with Tailwind @apply.
Loads cdn.tailwindcss.com only when you ask.
.vsel-control { @apply flex w-full items-center gap-2
rounded-xl border-2 border-rose-200 bg-white px-3.5
py-2.5 text-sm shadow-sm cursor-pointer; }
.vsel.is-open .vsel-control { @apply border-rose-500
ring-4 ring-rose-100; }
.vsel-value.is-placeholder { @apply text-slate-400; }
.vsel.is-open .vsel-arrow { @apply rotate-180 text-rose-500; }
.vsel.vsel-panel { @apply z-50 mt-1.5 rounded-2xl border
border-rose-100 bg-white p-1.5 shadow-2xl; }
.vsel-search input { @apply w-full rounded-lg border
border-slate-200 bg-slate-50 px-3 py-1.5 text-sm; }
.vsel-option { @apply flex items-center gap-2 rounded-lg
px-3 py-2 text-sm cursor-pointer; }
.vsel-option.is-active { @apply bg-rose-50 text-rose-700; }
.vsel-option.is-selected { @apply font-semibold text-rose-600; }
.vsel-option.is-disabled { @apply opacity-40 cursor-not-allowed; }
.vsel-check { @apply w-4 text-rose-500 opacity-0; }
.vsel-option.is-selected .vsel-check { @apply opacity-100; }
.vsel-empty { @apply px-3 py-6 text-center text-slate-400; }