vanilla-select-js
v1.0.0-beta.0
Published
Dependency-free vanilla JS select/dropdown inspired by react-select and Select2 with async search, debounce, nested tree options, and optional React adapter.
Maintainers
Readme
Vanilla Select JS
vanilla-select-js is a dependency-free select/dropdown package for modern web apps.
It is inspired by react-select and Select2, while keeping the runtime fully vanilla JavaScript and framework-agnostic.
Why this package
- No runtime dependencies in the core package
- Async search with built-in debounce
- Tree/nested options with unlimited depth
- Single and multi-select support
- Optional React adapter with TypeScript props
- SCSS + CSS variable based customization
- Default runtime style injection (no mandatory CSS import)
Installation
npm install vanilla-select-jsCompatibility
| Dependency | Version |
| --- | --- |
| Node.js | >= 14.x (recommended >= 18.x) |
| React | ^16.8.0 || ^17.0.0 || ^18.0.0 |
| react-dom | ^16.8.0 || ^17.0.0 || ^18.0.0 |
React/react-dom are only required when using vanilla-select-js/react. The core package stays framework-agnostic.
Overall Use Cases
- Basic single select for forms
- Multi-select with optional checkboxes (
showCheckbox) - Async remote option loading with debounce (
loadOptions) - Nested/tree select menus (
children/options) - Controlled menu debugging (
menuIsOpen) - Flexible preselected values (primitive/object inputs normalized automatically)
- Integration inside React, Vue, Angular, or plain JavaScript projects
Search Support (React-Select Style)
Yes, searching is supported.
searchable(boolean, defaulttrue): likeisSearchablein react-select.loadOptions(asyncor sync function): like AsyncSelect-style option loading.debounce(number, default250): built-in search debounce delay in ms.onSearch(optional callback): fires after search/filter cycle with{ query, total, loading }.
Notes:
- For static options, typing filters existing options.
- For remote options, provide
loadOptions(query)and return option objects. onSearchis notification-only. Search/filter still works even ifonSearchis not provided.onSearchruns after debounce, so console logs are not always instant on each keypress.
Demo Screenshots
Single Select

Nested Multi Select

Async Search

Basic Usage (Vanilla)
<div id="framework-select"></div>
<script type="module">
import Select from "vanilla-select-js";
const select = new Select("#framework-select", {
options: [
{ label: "React", value: "react" },
{ label: "Vue", value: "vue" },
{ label: "Angular", value: "angular" }
],
onChange: async (value, detail) => {
// single: { label, value } | null
// multi: [{ label, value }, ...]
console.log(value, detail);
}
});
window.select = select;
</script>Named import aliases are also available:
import { Select, JSSelect } from "vanilla-select-js";Async Search + Debounce
new Select("#users", {
debounce: 300,
loadOptions: async (query) => {
const response = await fetch(`/api/users?search=${encodeURIComponent(query)}`);
const users = await response.json();
return users.map((u) => ({ label: u.name, value: u.id }));
}
});Searchable (Local Options)
new Select("#languages", {
searchable: true,
options: [
{ label: "JavaScript", value: "js" },
{ label: "TypeScript", value: "ts" },
{ label: "Python", value: "py" },
{ label: "Go", value: "go" },
{ label: "Rust", value: "rust" }
],
onSearch: async ({ query, total }) => {
console.log("query:", query, "matched:", total);
}
});Nested/Tree Select
new Select("#tree", {
multiple: true,
showCheckbox: true,
options: [
{
label: "Frontend",
value: "frontend",
children: [
{
label: "Frameworks",
value: "frameworks",
children: [
{ label: "React", value: "react" },
{ label: "Vue", value: "vue" },
{ label: "Angular", value: "angular" }
]
}
]
}
]
});Default tree behavior is toggle-only for parent nodes (selectableParents: false).
If needed, make a specific parent selectable with selectable: true.
Value Normalization + menuIsOpen
Single select accepts either:
value: "react";
value: { label: "React", value: "react" };Multi-select accepts either:
value: ["react", "vue"];
value: [{ label: "React", value: "react" }, { label: "Vue", value: "vue" }];Controlled menu example:
new Select("#debug-select", {
options: [
{ label: "React", value: "react" },
{ label: "Vue", value: "vue" }
],
menuIsOpen: true
});React Usage (Typed TSX)
import { Select } from "vanilla-select-js/react";
import type { JSSelectProps, JSSelectOption } from "vanilla-select-js/react";
type FrameworkOption = JSSelectOption<string>;
const options: FrameworkOption[] = [
{ label: "React", value: "react" },
{ label: "Vue", value: "vue" },
{ label: "Angular", value: "angular" }
];
export default function FrameworkSelect() {
const props: JSSelectProps<string, FrameworkOption> = {
options,
multiple: true,
showCheckbox: true,
menuIsOpen: false,
messages: { placeholder: "Pick frameworks" }
};
return (
<Select
{...props}
onChange={async (selected) => {
console.log(selected);
}}
/>
);
}Backward-compatible React alias is still available:
import { JSSelect } from "vanilla-select-js/react";Hovering over <Select /> or <JSSelect /> in TS/TSX shows the available typed props.
Framework Wrapper Examples
Vue
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";
import Select from "vanilla-select-js";
const root = ref(null);
let instance;
onMounted(() => {
instance = new Select(root.value, {
options: [{ label: "Vue", value: "vue" }]
});
});
onBeforeUnmount(() => {
instance?.destroy();
});
</script>
<template>
<div ref="root" />
</template>Angular
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from "@angular/core";
import Select from "vanilla-select-js";
@Component({
selector: "app-select",
template: "<div #selectRoot></div>"
})
export class SelectComponent implements AfterViewInit, OnDestroy {
@ViewChild("selectRoot", { static: true }) selectRoot!: ElementRef<HTMLElement>;
private instance?: Select;
ngAfterViewInit(): void {
this.instance = new Select(this.selectRoot.nativeElement, {
options: [{ label: "Angular", value: "angular" }]
});
}
ngOnDestroy(): void {
this.instance?.destroy();
}
}Styling and Customization
1) Default styles (auto-injected)
By default, styles are injected automatically at runtime (injectStyles: true).
You do not need to import style.css for normal usage.
new Select("#default-styled", {
options: [{ label: "React", value: "react" }]
});Disable injection if you want full manual style control:
new Select("#manual-style", {
injectStyles: false,
options: [{ label: "React", value: "react" }]
});2) SCSS token overrides (build-time)
style.scss is still exported for advanced theming.
@use "vanilla-select-js/style.scss" with (
$js-select-border-color: #1f2937,
$js-select-control-bg: #f8fafc,
$js-select-option-selected-bg: #dbeafe
);3) CSS custom properties (runtime)
:root {
--js-select-border: #1f2937;
--js-select-bg: #ffffff;
--js-select-option-selected: #dbeafe;
}Class and Suffix Use Cases
Use className when you need a wrapper hook for page-level layout/theming:
new Select("#layout-hook", {
className: "settings-form-select",
options: [{ label: "React", value: "react" }]
});Use classNamePrefix to generate predictable slot class names for design systems:
new Select("#design-system", {
classNamePrefix: "acme-select",
options: [{ label: "React", value: "react" }]
});Use suffix to add a BEM modifier token across all generated classes:
new Select("#suffix-demo", {
suffix: "compact",
options: [{ label: "React", value: "react" }]
});With default prefix js-select, this adds modifier classes like:
js-select--compactjs-select__control--compactjs-select__option--compactjs-select__menu--compact
Migration / Backward Compatibility
- New recommended package/import naming is
vanilla-select-jsandSelect. - Existing runtime/class/component name
JSSelectis still exported for compatibility. - Core supports both
import Select from "vanilla-select-js"andimport { Select, JSSelect } from "vanilla-select-js". - React adapter supports both
import { Select } from "vanilla-select-js/react"andimport { JSSelect } from "vanilla-select-js/react".
API
Constructor
new Select(target: string | HTMLElement, config?: JSSelectConfig)Props (Core JSSelectConfig)
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| options | Option[] | [] | Option list (supports nested via children / options). |
| value | Value \\| {label,value} \\| Array<Value \\| {label,value}> | undefined | Initial/controlled selected value(s). |
| multiple | boolean | false | Enables multi-select mode. |
| disabled | boolean | false | Disables interactions. |
| searchable | boolean | true | Enables input typing/filtering (react-select isSearchable equivalent). |
| closeOnSelect | boolean | !multiple | Closes menu after selection in single mode by default. |
| clearable | boolean | true | Shows clear button when value exists. |
| menuIsOpen | boolean | undefined | Controlled menu visibility (debug/external control). |
| showCheckbox | boolean | false | Shows checkboxes for selectable options in multi mode. |
| injectStyles | boolean | true | Auto-injects default stylesheet. |
| inputId | string | auto-generated | Input id for external <label for=\"...\"> association. |
| name | string | undefined | Input name attribute. |
| ariaLabel | string | undefined | Accessible label text. |
| ariaLabelledBy | string | undefined | Id of element(s) that label this control. |
| ariaDescribedBy | string | undefined | Id of element(s) that describe this control. |
| debounce | number | 250 | Search debounce in ms (primarily for async loadOptions). |
| className | string | \"\" | Extra root class name(s). |
| classNamePrefix | string | \"js-select\" | Class prefix for generated slots. |
| classNames | Record<string, string \\| (state)=>string> | {} | Slot-level class overrides. |
| suffix | string | null | BEM modifier token applied to root and all slot classes. |
| selectableParents | boolean | false | Parent nodes selectable (false keeps toggle-only parents). |
| defaultExpanded | boolean | true | Expands tree parents initially. |
| messages | { placeholder?, noOptionsText?, loadingText?, clearText? } | defaults | UI message overrides. |
| loadOptions | (query, meta) => Promise<Option[]> \\| Option[] | null | Async/sync option loading for search. |
| renderOptionLabel | (option, state) => string \\| Node | null | Custom menu option label renderer. |
| renderValueLabel | (option, state) => string \\| Node | null | Custom selected value label renderer. |
| onChange | (value, detail) => void \\| Promise<void> | noop async | Selection change callback. |
| onOpen | (state) => void \\| Promise<void> | noop async | Menu open callback. |
| onClose | (state) => void \\| Promise<void> | noop async | Menu close callback. |
| onSearch | ({query,total,loading}) => void \\| Promise<void> | undefined | Optional informational callback after search results update. |
| onFocus | (state) => void \\| Promise<void> | noop async | Focus callback. |
| onBlur | (state) => void \\| Promise<void> | noop async | Blur callback. |
| onError | (error) => void \\| Promise<void> | noop async | Error callback. |
Notes:
messages.placeholderis shown as decorative text when unfocused, and as input placeholder when focused.- With
loadOptions, menu-open will fetch only for initial empty data or non-empty query (prevents repeated open calls with already-loaded options). - Local filtering works without
onSearch. - For proper external label support, pass
inputIdand use<label for=\"that-id\">.
Accessibility Example (label for + ARIA)
<label id="framework-label" for="framework-input">Framework</label>
<p id="framework-help">Type to search frameworks</p>
<div id="framework-select"></div>
<script type="module">
import Select from "vanilla-select-js";
new Select("#framework-select", {
inputId: "framework-input",
name: "framework",
ariaLabelledBy: "framework-label",
ariaDescribedBy: "framework-help",
options: [
{ label: "React", value: "react" },
{ label: "Vue", value: "vue" }
]
});
</script>Props (React Adapter JSSelectProps)
JSSelectProps includes all core JSSelectConfig props plus:
| Prop | Type | Description |
| --- | --- | --- |
| className | string | Wrapper class on the React container element. |
| style | React.CSSProperties | Inline style for wrapper container. |
| onReady | (instance) => void | Called with created core instance after mount. |
Instance methods
open()close()toggleMenu()focus()blur()search(query)setOptions(options)setValue(value)getValue()-> single{ label, value } | null, multiArray<{ label, value }>getSelectedOptions()clear()destroy()
Events
The root element dispatches bubbling custom events:
js-select:openjs-select:closejs-select:focusjs-select:blurjs-select:searchjs-select:changejs-select:error
Contributing
- Contribution guide:
CONTRIBUTING.md - Maintainer/developer docs:
docs/DEVELOPER_DOCUMENTATION.md
Search Tags / Keywords
Common discovery terms covered by this package and docs:
select, dropdown, multiselect, tree select, nested select, async select, react select,react select alternative, vanilla js select, custom select component, vanilla-select-js.
