Skip to content

fahim-tonmoy/vanilla-select-js

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

Compatibility

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.

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, default true): like isSearchable in react-select.
  • loadOptions (async or sync function): like AsyncSelect-style option loading.
  • debounce (number, default 250): 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.
  • onSearch is notification-only. Search/filter still works even if onSearch is not provided.
  • onSearch runs after debounce, so console logs are not always instant on each keypress.

Demo Screenshots

Single Select

Single Select Demo

Nested Multi Select

Nested Multi Select Demo

Async Search

Async Search Demo

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--compact
  • js-select__control--compact
  • js-select__option--compact
  • js-select__menu--compact

Migration / Backward Compatibility

  • New recommended package/import naming is vanilla-select-js and Select.
  • Existing runtime/class/component name JSSelect is still exported for compatibility.
  • Core supports both import Select from "vanilla-select-js" and import { Select, JSSelect } from "vanilla-select-js".
  • React adapter supports both import { Select } from "vanilla-select-js/react" and import { 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.placeholder is 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 inputId and 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, multi Array<{ label, value }>
  • getSelectedOptions()
  • clear()
  • destroy()

Events

The root element dispatches bubbling custom events:

  • js-select:open
  • js-select:close
  • js-select:focus
  • js-select:blur
  • js-select:search
  • js-select:change
  • js-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.