Vanilla UI Kit Select v1.0.0 · toast → · datepicker →

Selects, one file.

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>

Basic single

Progressive enhancement of a native select — keyboard, typeahead and theming included.

new Select('#fruit')

Searchable + clearable

Case- and diacritic-insensitive filter (try “zur”), plus a ✕ to reset.

new Select('#city', {
  searchable: true,
  clearable: true
})

Multiple with tags

Removable chips in the control, checkmarks in the list. Backspace removes the last tag.

new Select('#toppings', {
  searchable: true,
  maxItems: 3
})

Groups + disabled

<optgroup> becomes labelled groups; disabled options are skipped by the keyboard.

<optgroup label="Strings">…
<option disabled>Harp</option>

From an array

No native select needed — any container plus options; a hidden field carries name for forms.

value: —
new Select('#size', {
  name: 'size',
  options: ['S', 'M', 'L', 'XL'],
  onChange: v => show(v)
})

Inside a real form

The hidden native select submits as usual — no JS on the read side.

submitted: —
<form>
  <select name="plan">…</select>
  <select name="addons" multiple>…</select>
</form>

Zero-JS auto-init

Just data attributes — activated on load.

<select data-vsel data-vsel-searchable
  data-vsel-placeholder="Search countries…">

Enable / disable

Toggles the native disabled too, so submit semantics stay native.

sel.disable()  /  sel.enable()

API reference

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.

Constructor & usage

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:

1. Native <select> mode — progressive enhancement

Options, 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>

2. Container mode — built from an array

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>

Options

Every option, with the exact default from the Select.defaults object in the source. Any default can be changed globally, once: Select.defaults.placeholder = '…'.

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

Option item shape

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.

PropertyTypeDefaultDescription
valuestringfalls back to label Submitted / returned value.
labelstringfalls back to value Visible text. Rendered with textContent unless html is set.
disabledbooleanfalse Not selectable; skipped by keyboard navigation and typeahead.
groupstringnull Options sharing a group render under a labelled group heading (native <optgroup> maps to this).
htmlbooleanfalse Opt-in to render label as trusted HTML instead of text. Only use with markup you control.

Methods

All methods return the instance (chainable) except getValue(). On an inert SSR instance every method is a safe no-op.

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

Instance properties

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

Statics & helpers

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

Events & callbacks

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 eventCallbackevent.detailWhen
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)
})

Declarative init (data attributes)

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.

AttributeMaps toNotes
data-vsel— Marks the element for auto-init. Bound native selects also get a data-vsel-bound attribute.
data-vsel-searchablesearchable Boolean.
data-vsel-clearableclearable Boolean.
data-vsel-multiplemultiple 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>

Theming

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

PropertyDefault (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-softrgba(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-radius12pxsame 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.

Accessibility

Keyboard

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

ARIA & behavior notes


Headless mode

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.

Unstyled (styles: false)

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

Bring your own design (Tailwind)

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; }