Skip to content

Instantly share code, notes, and snippets.

@risuvoo
Last active December 13, 2025 10:12
Show Gist options
  • Select an option

  • Save risuvoo/ac35b5dfc784c8af890352979d81e169 to your computer and use it in GitHub Desktop.

Select an option

Save risuvoo/ac35b5dfc784c8af890352979d81e169 to your computer and use it in GitHub Desktop.
Custom Select Component

Custom Select Component

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.


Features

  • 💡 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 to width: 100% unless a width utility class is provided.
  • 📦 Multiple Instances – Automatically initializes all .custom-select elements on DOMContentLoaded.
  • 🧼 Click Outside to Close – Mirrors native select behaviour.

JavaScript API

Initialization

// 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";
    }
  });
});

Data Attributes

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

HTML Usage

Basic Select

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

Disable Search

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

Option Images (e.g., Flags)

<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-image attribute will display an image; others fall back to text-only.


CSS Styling

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.


Behaviour Summary

  • Clicking on .select-selected toggles 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.
  • handleOptionClick synchronizes the custom UI with the native <select> and dispatches a change event for integrations.
  • filterOptions runs only when search is enabled; it hides non-matching items and shows a “No options found” message when appropriate.

Tips

  • Wrap the .custom-select in a relatively positioned parent when using absolutely positioned SVG arrows.
  • Use Tailwind width classes (w-64, w-full, etc.) on .custom-select if you need a width other than the default 100%.
  • When using multiple selects on a page, simply replicate the HTML structure—no additional JS calls required.

Happy building! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment