Choices.js review: a vanilla JS select box that replaces jQuery Select2
A vanilla JS customisable select box/text input plugin ⚡️
At a glance
- What is it?
- Choices.js is an MIT-licensed JavaScript plugin that turns a plain select or text input into a searchable, taggable control without jQuery. The code is small and the API is broad, but the documentation leaves several behaviours, including rollback, unstated.
- Who is it for?
- Adopt Choices.js when you need a searchable multi-select or tag input on a page that has no jQuery and you want to style it yourself through CSS custom properties and class name overrides. Do not adopt it if you need a documented upgrade or rollback procedure, because the README does not cover either, or if you need a framework-native component with its own state model.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 16 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 30, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What Choices.js replaces, and for whom
The README describes Choices.js as "a vanilla, lightweight (~20kb gzipped), configurable select box/text input plugin" and places it next to Select2 and Selectize, with the difference stated plainly: no jQuery dependency. That single sentence defines the audience. If your page already loads jQuery, the argument for switching is weaker, because the plugin you have works and the migration cost is real. If you are building on modern bundlers, ES modules and no jQuery, Choices.js is aimed at you.
The scope is narrower than a full form library. It takes an existing select element or text input and layers behaviour on top: searching, filtering, adding items, removing items, and rendering selected values as tags. It does not manage form state, validation or submission. You still have a real select in the DOM, which matters for progressive enhancement and for anything that reads form values server-side.
Who it is for, concretely: teams shipping plain HTML and JavaScript, or a framework where a native element plus a wrapper is acceptable, who need a multi-select with search and want control over markup and styling. Who it is not for: anyone who wants the component to own application state.
How the plugin builds its DOM and where the search cost sits
Choices.js does not hide the original element and draw a fake one. It constructs a parallel structure around it, and the README exposes that structure through the classNames option, which lists the full set of generated hooks: containerOuter with the default class choices, containerInner (choices__inner), input (choices__input), inputCloned (choices__input--cloned), list and its variants (choices__list--multiple, choices__list--single, choices__list--dropdown), item, itemSelectable, itemDisabled, itemChoice, description, placeholder, group, groupHeading and button. State is expressed as separate classes: is-active, is-focused, is-open, is-disabled, is-highlighted. If you have ever fought a third-party widget whose internals were opaque, this list is the whole point: you can target the generated markup directly or rename every class through configuration.
The search side is where the package layout gets interesting. The exports map in package.json defines four additional entry points beyond the main one: choices.search-basic, choices.search-prefix, choices.search-kmp and choices.search-none. Each has its own import and require paths, for example ./public/assets/scripts/choices.search-kmp.mjs and ./public/assets/scripts/choices.search-kmp.min.js. The README does not explain what each algorithm does or when to pick one, so the naming is the only guide available: a prefix search, a KMP (Knuth-Morris-Pratt) substring search, a basic variant, and a build with no search at all. The practical reading is that the default bundle carries a general-purpose matcher and you can trade matching behaviour for bundle size by importing a narrower entry point. Verify against your own data before assuming the prefix bundle finds what the default one finds.
The main bundle is served as choices.js for require, choices.mjs for import, with types at ./public/types/src/index.d.ts. Styles ship separately as choices.css and, for Sass users, ./src/styles/choices.scss.
Installing Choices.js and wiring a first select
The README gives npm and Yarn as the package manager routes, and jsDelivr for a CDN. The npm package name is choices.js, not choices.
npm install choices.jsFor a bundler-based project, the module entry resolves to choices.mjs, so a plain import works. The README also shows importing the stylesheet from JavaScript, which webpack supports:
import "choices.js/public/assets/styles/choices.css";If you prefer Sass, the package exports the source stylesheet, and the README shows the import as:
@import "choices.js/src/styles/choices";For a no-build page, the README pins a CDN version in its examples and warns that the CDN sometimes lags the latest release, telling you to check the latest release and update the pinned version before using it. The two stylesheets are base.min.css (marked optional) and choices.min.css, plus the script:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css" />
<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>Instantiation is one line. The README shows passing a selector, a DOM reference or a jQuery element, and notes an important change: if the selector matches multiple elements, only the first is used, whereas versions before 8.x.x returned multiple instances.
const element = document.querySelector('.js-choice');
const choices = new Choices(element);Passing a second argument replaces the defaults for the keys you name. The README lists the full default set, including maxItemCount, delimiter, searchFields, shouldSort, allowHTML, classNames and the text strings such as noResultsText and itemSelectText. After the constructor runs, the page should show the styled control rather than the browser's native dropdown.
The configuration surface is wide, and that is the trade-off
The defaults block in the README is long. It covers selection behaviour (maxItemCount, removeItems, removeItemButton, duplicateItemsAllowed, editItems), input behaviour (delimiter, paste, addItems, addItemFilter), search behaviour (searchEnabled, searchChoices, searchFloor, searchResultLimit, searchFields), rendering (renderChoiceLimit, renderSelectedChoices, shouldSort, sorter, position, resetScrollPosition, placeholder) and text (loadingText, noResultsText, noChoicesText, itemSelectText, uniqueItemText, customAddItemText, maxItemText). Several of these accept functions rather than values: addItemText receives value and rawValue, maxItemText receives maxItemCount, and valueComparer receives two values and returns a boolean.
Two defaults deserve attention. First, allowHTML is false and allowHtmlUserInput is false. That is the safe setting, and it means labels containing markup are escaped rather than rendered. Turning allowHTML on is a deliberate decision about where your option labels come from. Second, duplicateItemsAllowed is true by default, so the same value can be added twice unless you change it. Both are the kind of default that looks harmless until it reaches production.
The width of the option set is also the main cost. Every key is another combination to test against your own markup, and the README presents them as a flat list rather than grouping them by task. There is a callbacks section, an events section, a methods section and a CSS custom properties section in the table of contents, so the intended integration path is: configure at construction, react through events and callbacks, and drive state changes through methods rather than by manipulating the generated DOM yourself.
What the README does not tell you
The documentation is silent on rollback. There is no described way to destroy an instance and restore the original element, and no upgrade or downgrade procedure. The CHANGELOG.md file exists at the repository root, so version history is recorded there, but the README does not walk through what changes between major versions or what breaks. The README does note one breaking change from before 8.x.x (multi-element selectors now return a single instance), which is the kind of note you would want for every major bump and do not get here.
Search behaviour is another gap. Four search entry points are exported, and the README does not document which algorithm each one uses or what input they are suited to. Choosing between them requires reading the source or testing.
The third limitation is structural rather than documentary. Because the plugin renders its own DOM alongside the original element, any code that expects to style or query the native control has to be updated to target the generated classes, or you have to override every class name through the classNames option. Neither route is free. In a design system with strict class naming, the override list is long.
Finally, this is a client-side widget with no server component. If your option list is large, the filtering happens in the browser, and the README's searchResultLimit default of 4 controls what is shown, not what is loaded.
Choices.js compared with Select2 and Selectize
The README itself names the comparison: Choices.js is "similar to Select2 and Selectize but without the jQuery dependency." That is the honest framing of the difference in approach. Select2 and Selectize were built in the jQuery plugin era and depend on jQuery being present. Choices.js assumes no jQuery and ships as an ES module with a CommonJS fallback, which fits projects already bundling their JavaScript.
The second difference is styling strategy. Choices.js exposes its generated class names as configuration and documents CSS custom properties as a first-class extension point. That means theming happens either by overriding the default choices__ classes or by renaming them to match your own conventions, and both are supported paths rather than hacks.
The third difference is the split search bundles. Select2's search behaviour is not something you swap by changing an import path; here the package exports four variants. If bundle size is the deciding factor and your filtering needs are simple, that is a concrete advantage. If you need the default matching semantics and cannot verify what the alternatives do from the documentation, it is a decision you have to make by reading code.
Licence, maintenance and upgrade cost
The licence is MIT, stated in the repository metadata and in the LICENSE file at the root. MIT is permissive: it allows use, modification and redistribution, including in closed-source products, provided the copyright notice and permission notice are retained. That is the general shape of the licence, not legal advice for your situation; if you redistribute the bundled script, keep the notice intact.
The repository is not archived, and the last push was on 2026-09-14. Releases are tagged: v11.2.4 on 2026-08-23, v11.2.3 on 2026-04-30, v11.2.2 on 2026-04-13. The version in package.json matches v11.2.4. Release cadence over that window is a patch roughly every few months, which tells you the project is in maintenance rather than rapid feature development.
Upgrade cost has a specific shape here. The package ships built artifacts in public/assets/scripts and types in public/types, so a version bump can change both the runtime behaviour and the type surface. The README documents one historical breaking change around the 8.x line. Because the README does not describe a rollback path, the practical approach is to pin the version in package.json and read CHANGELOG.md before bumping, especially across a major version. The repository also carries a browser support file (.browserslistrc), an ESLint config, a Prettier config, a Stylelint config and a Playwright config, so the project has its own lint and end-to-end test setup, but that says nothing about whether your integration will survive the upgrade.
Editorial conclusion
Adopt Choices.js when you need a searchable multi-select or tag input on a page that has no jQuery and you want to style it yourself through CSS custom properties and class name overrides. Do not adopt it if you need a documented upgrade or rollback procedure, because the README does not cover either, or if you need a framework-native component with its own state model. Before committing, check the version you install against the published release list, confirm which search bundle your build pulls in, and verify that the classNames and allowHTML settings match your output-escaping requirements.
Frequently asked questions
How do I install Choices.js with npm?
The README gives the command npm install choices.js, and the package name is choices.js. Yarn is listed as an alternative with yarn add choices.js.
Does Choices.js require jQuery?
No. The README describes it as a vanilla plugin and explicitly contrasts it with Select2 and Selectize on the point that it has no jQuery dependency. It does show passing a jQuery element to the constructor, but that is optional.
What happens if the Choices.js selector matches more than one element?
Only the first matching element is used. The README states that versions prior to 8.x.x would return multiple Choices instances instead.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/choices-js-choices)