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.
- 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)
npm install vanilla-select-js| Dependency | Version |
|---|---|
| Node.js | >= 14.x (recommended >= 18.x) |
| React | `^16.8.0 |
| react-dom | `^16.8.0 |
React/react-dom are only required when using vanilla-select-js/react. The core package stays framework-agnostic.
- 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
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.
<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";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 }));
}
});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);
}
});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.
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
});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.
<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>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();
}
}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" }]
});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
);:root {
--js-select-border: #1f2937;
--js-select-bg: #ffffff;
--js-select-option-selected: #dbeafe;
}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
- 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".
new Select(target: string | HTMLElement, config?: 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\">.
<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>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. |
open()close()toggleMenu()focus()blur()search(query)setOptions(options)setValue(value)getValue()-> single{ label, value } | null, multiArray<{ label, value }>getSelectedOptions()clear()destroy()
The root element dispatches bubbling custom events:
js-select:openjs-select:closejs-select:focusjs-select:blurjs-select:searchjs-select:changejs-select:error
- Contribution guide:
CONTRIBUTING.md - Maintainer/developer docs:
docs/DEVELOPER_DOCUMENTATION.md
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.


