SnapSelect is a lightweight, customizable, and easy-to-use JavaScript plugin designed to enhance the functionality of HTML <select> elements. Inspired by popular select plugins like Select2, TomSelect, and Slim Select, SnapSelect offers a modern and sleek interface with advanced features, while remaining highly configurable and performant.
- Live Search: Enable users to quickly filter options as they type.
- Ajax call: Remote data with infinite scrolling.
- Multi-Select Support: Allows for multiple selections with intuitive tag management.
- Optgroup Selection: Easily select or deselect all options within an optgroup.
- Clear Button: Option to show a clear button to reset the current selection (works for both single and multi-select).
- Select All Option: Add a "Select All" option to quickly select all available options (for multi-select).
- Customizable Placeholder: Define custom placeholder text for an empty selection.
- Custom Search Keywords: Enhance search functionality with custom keywords for each option.
- Bootstrap-Inspired Design: Styled to seamlessly integrate with Bootstrap 5, but easily customizable to match any design system.
- Callbacks:
onItemAddandonItemDeletehooks for reacting to individual selection changes. - Accessible: Built with accessibility in mind, ensuring a better experience for all users.
- Lightweight: Minimal footprint, optimized for performance.
Include the SnapSelect JavaScript and CSS files in your project:
<linkrel="stylesheet" href="/path/to/snapselect/dist/css/snapselect.min.css"><scriptsrc="/path/to/snapselect/dist/js/snapselect.min.js"></script>Initialize SnapSelect on your desired <select> elements using any selector:
<h3>Select without optgroups</h3><!-- Normal Select --><labelfor="selectNormal">Normal Select:</label><selectid="selectNormal" name="selectNormal" class="snapSelect"><optionvalue="AR" data-key="asado Buenos Aires Spanish">Argentina</option><optionvalue="AU" data-key="vegimite Canberra English">Australia</option><optionvalue="BR" data-key="feijoada Brasília Portuguese">Brazil</option><optionvalue="CN" data-key="dumplings Beijing Mandarin">China</option><optionvalue="FR" data-key="croissant Paris French">France</option><optionvalue="IN" data-key="curry New Delhi Hindi">India</option><optionvalue="JP" data-key="sushi Tokyo Japanese">Japan</option><optionvalue="MX" data-key="tacos Mexico City Spanish">Mexico</option><optionvalue="NG" data-key="jollof Abuja English">Nigeria</option><optionvalue="RU" data-key="borscht Moscow Russian">Russia</option><optionvalue="ZA" data-key="braai Pretoria English">South Africa</option><optionvalue="ES" data-key="paella Madrid Spanish">Spain</option><optionvalue="GB" data-key="fish and chips London English">United Kingdom</option><optionvalue="US" data-key="hamburger Washington D.C. English">United States</option></select><!-- Multiple Select --><labelfor="selectMultiple">Multiple Select:</label><selectid="selectMultiple" name="selectMultiple" multipleclass="snapSelect"><optionvalue="AR" data-key="asado Buenos Aires Spanish">Argentina</option><optionvalue="AU" data-key="vegimite Canberra English">Australia</option><optionvalue="BR" data-key="feijoada Brasília Portuguese">Brazil</option><optionvalue="CN" data-key="dumplings Beijing Mandarin">China</option><optionvalue="FR" data-key="croissant Paris French">France</option><optionvalue="IN" data-key="curry New Delhi Hindi">India</option><optionvalue="JP" data-key="sushi Tokyo Japanese">Japan</option><optionvalue="MX" data-key="tacos Mexico City Spanish">Mexico</option><optionvalue="NG" data-key="jollof Abuja English">Nigeria</option><optionvalue="RU" data-key="borscht Moscow Russian">Russia</option><optionvalue="ZA" data-key="braai Pretoria English">South Africa</option><optionvalue="ES" data-key="paella Madrid Spanish">Spain</option><optionvalue="GB" data-key="fish and chips London English">United Kingdom</option><optionvalue="US" data-key="hamburger Washington D.C. English">United States</option></select><h3>Select with optgroups</h3><!-- Select with Optgroups --><labelfor="selectOptgroupNormal">Normal Select with Optgroups:</label><selectid="selectOptgroupNormal" name="selectOptgroupNormal" class="snapSelect"><optgrouplabel="Africa"><optionvalue="ZA" data-key="braai Pretoria English">South Africa</option><optionvalue="NG" data-key="jollof Abuja English">Nigeria</option><optionvalue="EG" data-key="koshari Cairo Arabic">Egypt</option></optgroup><optgrouplabel="Asia"><optionvalue="CN" data-key="dumplings Beijing Mandarin">China</option><optionvalue="IN" data-key="curry New Delhi Hindi">India</option><optionvalue="JP" data-key="sushi Tokyo Japanese">Japan</option></optgroup><optgrouplabel="Europe"><optionvalue="FR" data-key="croissant Paris French">France</option><optionvalue="ES" data-key="paella Madrid Spanish">Spain</option><optionvalue="GB" data-key="fish and chips London English">United Kingdom</option></optgroup><optgrouplabel="North America"><optionvalue="US" data-key="hamburger Washington D.C. English">United States</option><optionvalue="MX" data-key="tacos Mexico City Spanish">Mexico</option><optionvalue="CA" data-key="poutine Ottawa English">Canada</option></optgroup><optgrouplabel="South America"><optionvalue="BR" data-key="feijoada Brasília Portuguese">Brazil</option><optionvalue="AR" data-key="asado Buenos Aires Spanish">Argentina</option><optionvalue="CO" data-key="arepa Bogotá Spanish">Colombia</option></optgroup><optgrouplabel="Oceania"><optionvalue="AU" data-key="vegimite Canberra English">Australia</option><optionvalue="NZ" data-key="hangi Wellington English">New Zealand</option><optionvalue="FJ" data-key="kokoda Suva English">Fiji</option></optgroup></select><!-- Multiple Select with Optgroups --><labelfor="selectOptgroupMultiple">Multiple Select with Optgroups and data attributes:</label><selectid="selectOptgroupMultiple" name="selectOptgroupMultiple" multipleclass="snapSelect"
data-live-search="true" data-placeholder="Choose an option" data-max-selections="3"
data-show-clear-button="true"
data-select-optgroups="true"
data-select-all-option="true"
data-close-on-select="false"><optgrouplabel="Africa"><optionvalue="ZA" data-key="braai Pretoria English">South Africa</option><optionvalue="NG" data-key="jollof Abuja English">Nigeria</option><optionvalue="EG" data-key="koshari Cairo Arabic">Egypt</option></optgroup><!-- ... --></select><script>document.addEventListener('DOMContentLoaded',()=>{SnapSelect('.snapSelect',{liveSearch: true,placeholder: 'Select an option...',showClearButton: true,selectOptgroups: true,selectAllOption: true,closeOnSelect: false,maxSelections: 10,});});</script>Use the ajax option to load options from a remote endpoint. The select element can be left empty; SnapSelect will populate it on open.
<selectid="selectAjax" data-placeholder="Search users..."></select><script>SnapSelect('#selectAjax',{liveSearch: true,ajax: {url: '/api/users',}});</script>The server receives q (search term), page, and pagesize as query parameters on every request:
GET /api/users?q=john&page=1&pagesize=20
Expected response shape:
{
"data": [
{ "id": 1, "text": "John Doe" },
{ "id": 2, "text": "John Smith", "disabled": "disabled" },
{ "id": 3, "text": "Jane Doe" }
],
"hasMore": true
}Any properties beyond id, text and disabled are stored as data-* attributes on the underlying <option>
If disabled is set, the option will be set to disabled regardless of the value given.
A result entry may also be an optgroup: { text: 'Label', children: [{id, text, ...extras}], ...groupExtras }.
Group extras beyond text/children are stored as data-* attributes on the underlying <optgroup>
element (accessible via option.parentElement.dataset); disabled disables the whole group and all of
its options. Child entries follow the exact same rules as top-level options.
Use the processResults option to parse the ajax answer and convert it to the expected format. In this example we get TotalRecordCount from the backend, indicating the total number of records
<selectid="selectAjax" data-placeholder="Search users..."></select><script>SnapSelect('#selectAjax',{liveSearch: true,ajax: {url: '/api/users',processResults: function(data,search,page){constrecords=Array.isArray(data)
? data
: (data.Records||[]);consttotal=data.TotalRecordCount!==undefined
? data.TotalRecordCount
: records.length;lethasMore=total>page*pagesize;// the next if covers the case where all rows are returned in 1 go, not respecting pagingif(data.TotalRecordCount!==undefined&&records.length>=data.TotalRecordCount)hasMore=false;return{results: records, hasMore };}}});</script>The server receives q (search term), page, and pagesize as query parameters on every request:
GET /api/users?q=john&page=1&pagesize=2
Example response shape (with an optgroup example):
{
"data": [
{ id: 'us', text: 'United States' },
{ text: 'Europe', countryCode: 'eu', children: [
{ id: 'be', text: 'Belgium', capital: 'Brussels' },
{ id: 'fr', text: 'France', disabled: true },
]},
{ text: 'Locked', disabled: true, children: [
{ id: 'x1', text: 'X1' },
]},
],
"TotalRecordCount": 10
}Any properties beyond id, text and disabled are stored as data-* attributes on the underlying <option>
If disabled, the option will be set to disabled regardless of the value given.
A result entry may also be an optgroup: { text: 'Label', children: [{id, text, ...extras}], ...groupExtras }.
Group extras beyond text/children are stored as data-* attributes on the underlying <optgroup>
element (accessible via option.parentElement.dataset); disabled disables the whole group and all of
its options. Child entries follow the exact same rules as top-level options.
Use data to pass extra parameters (as a plain object or a function), and method to switch to POST. For POST requests, parameters are sent as multipart/form-data, which is compatible with traditional server frameworks like WordPress:
<selectid="selectAjaxPost" data-placeholder="Search products..."></select><script>// Plain object — same extra params on every requestSnapSelect('#selectAjaxPost',{ajax: {url: '/api/products/search',method: 'POST',data: {category: 'electronics',inStock: true},processResults: function(response){return{results: response.items,hasMore: response.hasNextPage};}}});// Function — extra params can vary per search/pageSnapSelect('#selectAjaxPost',{ajax: {url: '/api/products/search',method: 'POST',data: function(searchTerm,page){return{category: document.getElementById('categoryFilter').value};},processResults: function(data,search,page){return{results: data.items,hasMore: data.hasNextPage};}}});</script>The url option accepts a function, making dependent dropdowns straightforward:
<selectid="country" data-placeholder="Select country..."></select><selectid="state" data-placeholder="Select state..." disabled></select><script>SnapSelect('#country',{ajax: {url: '/api/countries',processResults: r=>({results: r.data,hasMore: r.meta.hasMore})}});document.getElementById('country').addEventListener('change',function(){conststateEl=document.getElementById('state');stateEl.disabled=!this.value;SnapSelect('#state',{ajax: {url: (search,page)=>`/api/states?country=${this.value}&q=${search}&page=${page}`,processResults: r=>({results: r.data,hasMore: r.meta.hasMore})}});});</script>SnapSelect('#selectAjax',{ajax: {url: '/api/search',minimumInputLength: 2,// only fetch after 2 characters are typeddelay: 400,// debounce delay in msheaders: {'Authorization': 'Bearer my-token','X-Custom-Header': 'value'},processResults: function(data,search,page){return{results: data.results,hasMore: false};}}});The change-event and input-event on the underlying <select> are still emitted, so you can hook into those as usual.
data-live-search="true|false"(boolean): Enables or disables the search functionality.data-max-selections="number"(number): Sets the maximum number of selections allowed.data-max-items="number"(number): Alias fordata-max-selections.data-placeholder="text"(string): Sets the placeholder text when no option is selected.data-show-clear-button="true|false"(boolean): Shows or hides the clear button (works for both single and multi-select). Deprecated aliases:data-clear-all-button,data-allow-empty.data-select-optgroups="true|false"(boolean): Allows or disallows selecting all options within an optgroup.data-select-all-option="true|false"(boolean): Adds or removes the "Select All" option.data-close-on-select="true|false"(boolean): Closes or keeps open the dropdown after selection.data-open-on-focus="true|false"(boolean): If true, opens the dropdown after focus.
liveSearch(boolean): Enable live search functionality. Default:false.maxSelections(number): Maximum number of selections allowed (for multi-select). Alias:maxItems. Default:Infinity.placeholder(string): Custom placeholder text. Default: ``. This causes an empty option to be added at the top if not present.defaultText(string): the text shown on the select box if the first option value and string are empty (placeholder takes precedence if present). Default:Select...'.showClearButton(boolean): Show a button to clear the current selection. For multi-select, clears all tags at once; for single-select, reverts to the placeholder. Default:trueifplaceholderis set,falseotherwise. Deprecated aliases:clearAllButton,allowEmpty.selectOptgroups(boolean): Allow selecting/deselecting all options within an optgroup. Default:false.selectAllOption(boolean): Add a "Select All" option for multi-select. Default:false.closeOnSelect(boolean): Close the dropdown after each selection (single-select only). Default:true.openOnFocus(boolean): Opens the dropdown automatically when the select receives focus (e.g. via Tab). Default:false.openOnFocusIfEmoty(boolean): Opens the dropdown automatically when the select receives focus (e.g. via Tab) and nothing is selected yet. Default:false.
onItemAdd(function): Called whenever an item is selected. Receives(value, text)— the option'svalueattribute and its visible label text. Fired for both single and multi-select. Inside the callback,thisrefers to the underlying<select>element.onItemDelete(function): Called whenever an item is deselected. Receives(value, text). In multi-select mode this fires per individual item removed (whether via the tag × button or the dropdown checkbox). In single-select mode it also fires for the previously selected value when it is replaced by a new selection. Inside the callback,thisrefers to the underlying<select>element.
SnapSelect('#mySelect',{onItemAdd: function(value,text){// `this` is the <select> elementconstform=this.closest('form');console.log('Added:',value,text);},onItemDelete: function(value,text){// `this` is the <select> elementconstform=this.closest('form');console.log('Removed:',value,text);}});Both callbacks work in static and AJAX modes and are independent of the native change event — each receives exactly the one value that changed, making them more precise than listening to change directly on the underlying <select>.
ajax(object): Enables remote data loading. When set, the dropdown fetches options from a server instead of reading them from the HTML. Default:null.url(string|function) required: The endpoint URL, or a function(searchTerm, page) => stringfor dynamic URLs. When a function,thisrefers to the underlying<select>element. When a function, this also changes the default cache value (see that option).method(string): HTTP method. Default:'GET'.data(object|function): Optional. A plain object of extra parameters, or a function(searchTerm, page) => object, merged into every request. When a function,thisrefers to the underlying<select>element. When a function, this also changes the default cache value (see that option).processResults(function): Optional. Maps the raw server response to{ results: [...], hasMore: bool }.thisrefers to the underlying<select>element. If omitted, SnapSelect expects the response to be either a plain array or an object with aresultsarray and ahasMoreboolean. Each result entry is one of:- an option:
{ id, text, disabled?, ...extras }. Any extra properties beyondid,textanddisabledare stored asdata-*attributes on the underlying<option>element, making them accessible inonItemAdd/onItemDeleteviathis.querySelector(\option[value="${value}"]`).dataset`. - an optgroup:
{ text: 'Label', children: [{id, text, ...}], disabled?, ...extras }. The group label comes fromtext(orlabel);disableddisables the whole group and all of its options. Group extras beyondtext,label,childrenanddisabledare stored asdata-*attributes on the underlying<optgroup>element, accessible viaoption.parentElement.dataset. Child entries follow the exact same rules as top-level options.
- an option:
delay(number): Debounce delay in ms before firing a search request. Default:300.minimumInputLength(number): Minimum number of characters required before fetching. Default:0.cache(boolean): Cache results per (search + page) key to avoid redundant requests. Default:falseifurlordatais a function,trueotherwise.headers(object): Extra HTTP headers to include in every request. Default:{}.pagesize(number): Number of items per page sent to the server. Default:20.loadingText(string): Text shown in the dropdown while loading. Default:'Loading...'.noResultsText(string): Text shown when no results are returned. Default:'No results found'.errorText(string): Text shown when the request fails. Default:'Error loading results'.
After initialisation, SnapSelect returns an instance with the following methods:
constselect=SnapSelect('#mySelect',{/* options */});// Clear the current selectionselect.clear();// Force a fresh AJAX fetch (clears cache for the current search term)select.refresh();// Clear the entire AJAX response cacheselect.clearCache();// Sets and refreshes the placeholderselect.setPlaceholder('my new placeholder text');SnapSelect respects the native required attribute. When a required field is submitted empty, the widget highlights the select with a snap-select-invalid class on the .snap-select-selected element and renders a validation message below the widget using .snap-select-validation-message. Both classes can be styled freely in your CSS.
<selectid="selectRequired" name="category" requireddata-placeholder="Select a category..."><optionvalue=""></option><optionvalue="1">Option A</option><optionvalue="2">Option B</option></select>The validation message text is sourced from the browser's own validationMessage, so it is automatically localised.
SnapSelect is designed with accessibility in mind and includes ARIA attributes to enhance usability with assistive technologies.
- The main container is set as
role="combobox"witharia-expandedandaria-haspopupattributes. - The items container is set as
role="listbox". - Selected items are dynamically updated with
aria-live="polite"for screen reader notifications.
Contributions are welcome! Please fork the repository and submit a pull request for any features, bug fixes, or improvements.
SnapSelect is released under the MIT License. See the LICENSE file for more details. Feel free to use in any condition and modify all as you want.

