This repository includes a fully custom select implementation that replaces the native <select> control with an accessible, keyboard-friendly, and themeable component written in vanilla JavaScript and Tailwind CSS.
- 💡 Drop-in Replacement – Works with any
<select>and keeps the original element in sync for form submissions. - 🔎 Searchable Dropdown – Enable/disable per select via
data-search. - 🖼 Optional Option Images – Show an icon or flag per option with
data-show-images+data-image. ↔️ Responsive Width – Defaults towidth: 100%unless a width utility class is provided.- 📦 Multiple Instances – Automatically initializes all
.custom-selectelements onDOMContentLoaded. - 🧼 Click Outside to Close – Mirrors native select behaviour.
// custom select start
class CustomSelect {
constructor(container) {
this.container = container;
this.select = container.querySelector("select");
this.selectedDiv = null;
this.optionsDiv = null;
this.searchInput = null;
this.optionElements = [];
this.allOptions = [];
// Check if search is enabled (default: true, disable with data-search="false")
this.searchEnabled = this.container.dataset.search !== "false";
// Check if images should be shown (enable with data-show-images="true")
this.showImages = this.container.dataset.showImages === "true";
this.init();
}
init() {
// Create the selected item div
this.selectedDiv = document.createElement("div");
this.selectedDiv.className = "select-selected";
const selectedOption = this.select.options[this.select.selectedIndex];
this.updateSelectedDisplay(selectedOption);
this.container.appendChild(this.selectedDiv);
// Create the options container div
this.optionsDiv = document.createElement("div");
this.optionsDiv.className = "select-items hidden";
// Create search input only if search is enabled
if (this.searchEnabled) {
this.searchInput = document.createElement("input");
this.searchInput.type = "text";
this.searchInput.className = "select-search-input";
this.searchInput.placeholder = "Search options...";
this.searchInput.addEventListener("input", (e) =>
this.filterOptions(e.target.value)
);
this.searchInput.addEventListener("click", (e) => e.stopPropagation());
this.optionsDiv.appendChild(this.searchInput);
}
// Store all options data
Array.from(this.select.options).forEach((option) => {
this.allOptions.push({
option: option,
text: option.text,
value: option.value,
});
});
// Add options to the options container
const selectedIndex = this.select.selectedIndex;
this.allOptions.forEach((optionData, index) => {
const optionDiv = document.createElement("div");
optionDiv.className = "select-option";
optionDiv.setAttribute("data-value", optionData.value);
// Create content wrapper for image + text
const contentWrapper = document.createElement("div");
contentWrapper.className = "select-option-content";
// Add image if enabled and available
if (this.showImages && optionData.option.getAttribute("data-image")) {
const img = document.createElement("img");
img.src = optionData.option.getAttribute("data-image");
img.alt = optionData.text;
img.className = "select-option-image";
contentWrapper.appendChild(img);
}
// Add text
const textSpan = document.createElement("span");
textSpan.textContent = optionData.text;
textSpan.className = "select-option-text";
contentWrapper.appendChild(textSpan);
optionDiv.appendChild(contentWrapper);
// Mark the currently selected option
if (index === selectedIndex) {
optionDiv.classList.add("same-as-selected");
}
// Ensure all options are visible when search is disabled
if (!this.searchEnabled) {
optionDiv.style.display = "";
}
optionDiv.addEventListener("click", () =>
this.handleOptionClick(optionData.option, optionDiv)
);
this.optionsDiv.appendChild(optionDiv);
this.optionElements.push(optionDiv);
});
this.container.appendChild(this.optionsDiv);
// Set width to 100% (only if no width class is set)
const hasWidthClass = Array.from(this.container.classList).some((cls) =>
/^(w-|min-w-|max-w-)/.test(cls)
);
if (!hasWidthClass) {
this.container.style.width = "100%";
}
// Toggle options visibility when the selected item is clicked
this.selectedDiv.addEventListener("click", (e) => {
e.stopPropagation();
this.toggleOptions();
});
// Close all select boxes when clicking outside
document.addEventListener("click", () => this.closeOptions());
}
filterOptions(searchQuery) {
// Only filter if search is enabled
if (!this.searchEnabled) return;
const query = searchQuery.toLowerCase().trim();
let hasVisibleOptions = false;
this.optionElements.forEach((optionDiv) => {
// Get text from the text span, not the whole div (to exclude image)
const textSpan = optionDiv.querySelector(".select-option-text");
const optionText = textSpan
? textSpan.textContent.toLowerCase()
: optionDiv.textContent.toLowerCase();
if (optionText.includes(query)) {
optionDiv.style.display = "";
hasVisibleOptions = true;
} else {
optionDiv.style.display = "none";
}
});
// Show/hide a "no results" message if needed
let noResultsMsg = this.optionsDiv.querySelector(".select-no-results");
if (!hasVisibleOptions && query !== "") {
if (!noResultsMsg) {
noResultsMsg = document.createElement("div");
noResultsMsg.className = "select-no-results";
noResultsMsg.textContent = "No options found";
this.optionsDiv.appendChild(noResultsMsg);
}
noResultsMsg.style.display = "";
} else if (noResultsMsg) {
noResultsMsg.style.display = "none";
}
}
handleOptionClick(option, optionDiv) {
// Update the selected item and the original select element
this.updateSelectedDisplay(option);
this.select.selectedIndex = Array.from(this.select.options).indexOf(option);
// Trigger the change event manually
const event = new Event("change", { bubbles: true });
this.select.dispatchEvent(event);
// Highlight the selected option (only select-option divs, not search input or no-results)
this.optionElements.forEach((div) =>
div.classList.remove("same-as-selected")
);
optionDiv.classList.add("same-as-selected");
// Close the options dropdown
this.closeOptions();
}
updateSelectedDisplay(option) {
// Clear existing content
this.selectedDiv.innerHTML = "";
// Add image if enabled and available
if (this.showImages && option.getAttribute("data-image")) {
const img = document.createElement("img");
img.src = option.getAttribute("data-image");
img.alt = option.text;
img.className = "select-selected-image";
this.selectedDiv.appendChild(img);
}
// Add text
const textSpan = document.createElement("span");
textSpan.textContent = option.text;
textSpan.className = "select-selected-text";
this.selectedDiv.appendChild(textSpan);
}
toggleOptions() {
// Close all other select boxes
CustomSelect.closeAllSelects(this.container);
// Check if opening before toggling
const isOpening = this.optionsDiv.classList.contains("hidden");
// Toggle the current select box
this.optionsDiv.classList.toggle("hidden");
this.selectedDiv.classList.toggle("select-arrow-active");
// Rotate SVG arrow if it exists
const svgArrow = this.container.querySelector("svg");
if (svgArrow) {
if (isOpening) {
svgArrow.style.transform = "translateY(-50%) rotate(180deg)";
} else {
svgArrow.style.transform = "translateY(-50%) rotate(0deg)";
}
}
// If opening and search is enabled, focus the search input and reset filter
if (isOpening && this.searchEnabled && this.searchInput) {
setTimeout(() => {
this.searchInput.focus();
this.searchInput.value = "";
this.filterOptions("");
}, 10);
}
}
closeOptions() {
this.optionsDiv.classList.add("hidden");
this.selectedDiv.classList.remove("select-arrow-active");
// Reset SVG arrow rotation
const svgArrow = this.container.querySelector("svg");
if (svgArrow) {
svgArrow.style.transform = "translateY(-50%) rotate(0deg)";
}
// Clear search when closing (only if search is enabled)
if (this.searchEnabled && this.searchInput) {
this.searchInput.value = "";
this.filterOptions("");
}
}
static closeAllSelects(exceptContainer) {
// Close all select boxes except the one passed as an argument
document.querySelectorAll(".select-items").forEach((optionsDiv) => {
if (optionsDiv.parentElement !== exceptContainer) {
optionsDiv.classList.add("hidden");
}
});
document.querySelectorAll(".select-selected").forEach((selectedDiv) => {
if (selectedDiv.parentElement !== exceptContainer) {
selectedDiv.classList.remove("select-arrow-active");
}
});
}
}
document.addEventListener("DOMContentLoaded", () => {
const customSelects = document.querySelectorAll(".custom-select");
customSelects.forEach((container) => {
const select = container.querySelector("select");
if (select && !container.dataset.customSelectInitialized) {
new CustomSelect(container);
container.dataset.customSelectInitialized = "true";
}
});
});| Attribute | Default | Description |
|---|---|---|
data-search="false" |
true |
Disables the search input for that select. |
data-show-images="true" |
false |
Enables per-option images. Requires data-image on <option>. |
<div class="custom-select">
<select name="country" class="hidden">
<option value="">Select a country</option>
<option value="bd">Bangladesh</option>
<option value="in">India</option>
</select>
<svg
class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
width="10"
height="6"
viewBox="0 0 10 6"
>
<path
d="M0.834 1.334L5.001 4.667 9.167 1.334"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div><div class="custom-select" data-search="false">
<select name="status" class="hidden">
<option value="">Select status</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
</div><div class="custom-select" data-show-images="true">
<select name="currency" class="hidden">
<option value="usd" data-image="./assets/images/flag-us.png">USD</option>
<option value="eur" data-image="./assets/images/flag-eu.png">EUR</option>
<option value="gbp" data-image="./assets/images/flag-uk.png">GBP</option>
</select>
<!-- Optional custom arrow SVG -->
<svg
class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
width="10"
height="6"
viewBox="0 0 10 6"
>
<path
d="M0.834 1.334L5.001 4.667 9.167 1.334"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>ℹ️ Only options with a
data-imageattribute will display an image; others fall back to text-only.
The component relies on Tailwind’s @apply utilities. Relevant selectors are located near the /* custom select style start */ section in assets/css/input.css.
Key classes:
.custom-select {
@apply w-full h-[44px] border border-grayscale-200 rounded-[2px] bg-grayscale-50 px-[15px] relative;
& .select-selected {
@apply relative flex items-center gap-2 bg-transparent text-headline h-full cursor-pointer;
}
& .select-items {
@apply absolute bg-white border border-grayscale-300 rounded top-full left-0 right-0 z-50;
}
& .select-items .select-option-content {
@apply text-paragraph py-1.5 cursor-pointer;
}
.select-items {
@apply px-3 py-2;
animation: customSelect 0.3s ease-out;
}
& :is(.select-selected-image, .select-option-image) {
width: 20px;
height: 20px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
& .select-option-content {
@apply flex items-center gap-2;
}
& .select-search-input {
@apply w-full px-3 py-1 h-10 border-b border-grayscale-300 text-16 outline-none bg-white focus:border-gray-300;
}
}
Feel free to add custom styles or override Tailwind utilities for different themes.
- Clicking on
.select-selectedtoggles the dropdown, rotates the arrow (native SVG or pseudo-element), and focuses the search field if enabled. - Clicking outside closes the dropdown and resets the arrow.
handleOptionClicksynchronizes the custom UI with the native<select>and dispatches achangeevent for integrations.filterOptionsruns only when search is enabled; it hides non-matching items and shows a “No options found” message when appropriate.
- Wrap the
.custom-selectin a relatively positioned parent when using absolutely positioned SVG arrows. - Use Tailwind width classes (
w-64,w-full, etc.) on.custom-selectif you need a width other than the default100%. - When using multiple selects on a page, simply replicate the HTML structure—no additional JS calls required.
Happy building! 🎉