Select Dropdowns
MediumSingle and multi-select dropdowns with search functionality and custom styling
Updated about 2 months ago
Tested & Responsive
Live Demo
Preview:
Desktop view
Basic Select Dropdown
United States
Canada
United Kingdom
Australia
Searchable Select
React
Vue.js
Stimulus
Resize to see how the component adapts to different screen sizes
<div class="space-y-8">
<!-- Basic Select Dropdown -->
<div class="space-y-6">
<h3 class="text-lg font-semibold text-green-800 mb-4 text-center">Basic Select Dropdown</h3>
<div class="max-w-md mx-auto">
<div data-controller="select" data-select-placeholder-value="Choose a country...">
<label class="block text-sm font-medium text-green-900 mb-2">Country</label>
<div class="relative">
<button
data-select-target="button"
data-action="click->select#toggle"
type="button"
class="relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500 sm:text-sm"
aria-haspopup="listbox"
aria-expanded="false"
>
<span data-select-target="selected" class="block truncate text-gray-400">Choose a country...</span>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4"></path>
</svg>
</span>
</button>
<div data-select-target="dropdown" class="hidden absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="us"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">United States</span>
<span class="checkmark absolute inset-y-0 right-0 flex items-center pr-4 text-green-600 hidden">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="ca"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">Canada</span>
<span class="checkmark absolute inset-y-0 right-0 flex items-center pr-4 text-green-600 hidden">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="uk"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">United Kingdom</span>
<span class="checkmark absolute inset-y-0 right-0 flex items-center pr-4 text-green-600 hidden">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="au"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">Australia</span>
<span class="checkmark absolute inset-y-0 right-0 flex items-center pr-4 text-green-600 hidden">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
</div>
</div>
<input data-select-target="hiddenInput" type="hidden" name="country" />
</div>
</div>
</div>
<!-- Searchable Select -->
<div class="space-y-6">
<h3 class="text-lg font-semibold text-green-800 mb-4 text-center">Searchable Select</h3>
<div class="max-w-md mx-auto">
<div data-controller="select" data-select-searchable-value="true" data-select-placeholder-value="Search for a framework...">
<label class="block text-sm font-medium text-green-900 mb-2">JavaScript Framework</label>
<div class="relative">
<button
data-select-target="button"
data-action="click->select#toggle"
type="button"
class="relative w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500 sm:text-sm"
aria-haspopup="listbox"
aria-expanded="false"
>
<span data-select-target="selected" class="block truncate text-gray-400">Search for a framework...</span>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</span>
</button>
<div data-select-target="dropdown" class="hidden absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md text-base ring-1 ring-black ring-opacity-5 overflow-hidden focus:outline-none sm:text-sm">
<div class="p-2 border-b border-gray-200">
<input
data-select-target="search"
data-action="input->select#search"
type="text"
placeholder="Search frameworks..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500 text-sm"
/>
</div>
<div class="max-h-48 overflow-auto py-1">
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="react"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">React</span>
</div>
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="vue"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">Vue.js</span>
</div>
<div
data-select-target="option"
data-action="click->select#selectOption"
data-value="stimulus"
class="cursor-default select-none relative py-2 pl-3 pr-9 text-gray-900 hover:bg-gray-100"
tabindex="-1"
>
<span class="block truncate">Stimulus</span>
</div>
</div>
</div>
</div>
<input data-select-target="hiddenInput" type="hidden" name="framework" />
</div>
</div>
</div>
</div>
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="select"
export default class extends Controller {
static targets = ["button", "dropdown", "option", "search", "selected", "hiddenInput"]
static values = {
multiple: Boolean,
searchable: Boolean,
placeholder: String,
value: String
}
connect() {
this.selectedValues = this.multipleValue ? [] : null
this.isOpen = false
// Set initial value if provided
if (this.valueValue) {
if (this.multipleValue) {
this.selectedValues = this.valueValue.split(',')
} else {
this.selectedValues = this.valueValue
}
this.updateDisplay()
}
// Close dropdown when clicking outside
this.boundCloseOnOutsideClick = this.closeOnOutsideClick.bind(this)
document.addEventListener("click", this.boundCloseOnOutsideClick)
// Handle keyboard navigation
this.boundHandleKeydown = this.handleKeydown.bind(this)
document.addEventListener("keydown", this.boundHandleKeydown)
}
disconnect() {
document.removeEventListener("click", this.boundCloseOnOutsideClick)
document.removeEventListener("keydown", this.boundHandleKeydown)
}
toggle() {
if (this.isOpen) {
this.close()
} else {
this.open()
}
}
open() {
this.isOpen = true
this.dropdownTarget.classList.remove("hidden")
this.buttonTarget.setAttribute("aria-expanded", "true")
// Focus search input if searchable
if (this.searchableValue && this.hasSearchTarget) {
this.searchTarget.focus()
}
// Scroll to selected option
this.scrollToSelected()
}
close() {
this.isOpen = false
this.dropdownTarget.classList.add("hidden")
this.buttonTarget.setAttribute("aria-expanded", "false")
// Clear search
if (this.searchableValue && this.hasSearchTarget) {
this.searchTarget.value = ""
this.filterOptions("")
}
}
selectOption(event) {
const option = event.currentTarget
const value = option.dataset.value
const text = option.textContent.trim()
if (this.multipleValue) {
this.toggleMultipleSelection(value, text, option)
} else {
this.setSingleSelection(value, text, option)
this.close()
}
this.updateHiddenInput()
this.dispatchChangeEvent()
}
setSingleSelection(value, text, option) {
// Clear previous selection
this.optionTargets.forEach(opt => {
opt.classList.remove("bg-green-100", "text-green-900")
opt.classList.add("text-gray-900")
opt.querySelector('.checkmark')?.classList.add('hidden')
})
// Set new selection
this.selectedValues = value
option.classList.add("bg-green-100", "text-green-900")
option.classList.remove("text-gray-900")
option.querySelector('.checkmark')?.classList.remove('hidden')
this.updateDisplay()
}
toggleMultipleSelection(value, text, option) {
const index = this.selectedValues.indexOf(value)
if (index > -1) {
// Remove selection
this.selectedValues.splice(index, 1)
option.classList.remove("bg-green-100", "text-green-900")
option.classList.add("text-gray-900")
option.querySelector('.checkmark')?.classList.add('hidden')
} else {
// Add selection
this.selectedValues.push(value)
option.classList.add("bg-green-100", "text-green-900")
option.classList.remove("text-gray-900")
option.querySelector('.checkmark')?.classList.remove('hidden')
}
this.updateDisplay()
}
updateDisplay() {
if (!this.hasSelectedTarget) return
if (this.multipleValue) {
if (this.selectedValues.length === 0) {
this.selectedTarget.textContent = this.placeholderValue || "Select options..."
this.selectedTarget.classList.add("text-gray-400")
} else if (this.selectedValues.length === 1) {
const option = this.optionTargets.find(opt => opt.dataset.value === this.selectedValues[0])
this.selectedTarget.textContent = option ? option.textContent.trim() : this.selectedValues[0]
this.selectedTarget.classList.remove("text-gray-400")
} else {
this.selectedTarget.textContent = `${this.selectedValues.length} selected`
this.selectedTarget.classList.remove("text-gray-400")
}
} else {
if (this.selectedValues) {
const option = this.optionTargets.find(opt => opt.dataset.value === this.selectedValues)
this.selectedTarget.textContent = option ? option.textContent.trim() : this.selectedValues
this.selectedTarget.classList.remove("text-gray-400")
} else {
this.selectedTarget.textContent = this.placeholderValue || "Select an option..."
this.selectedTarget.classList.add("text-gray-400")
}
}
}
updateHiddenInput() {
if (!this.hasHiddenInputTarget) return
if (this.multipleValue) {
this.hiddenInputTarget.value = this.selectedValues.join(',')
} else {
this.hiddenInputTarget.value = this.selectedValues || ''
}
}
search(event) {
const query = event.target.value.toLowerCase()
this.filterOptions(query)
}
filterOptions(query) {
this.optionTargets.forEach(option => {
const text = option.textContent.toLowerCase()
const matches = text.includes(query)
if (matches) {
option.classList.remove("hidden")
} else {
option.classList.add("hidden")
}
})
}
scrollToSelected() {
if (this.multipleValue) return
const selectedOption = this.optionTargets.find(option =>
option.dataset.value === this.selectedValues
)
if (selectedOption) {
selectedOption.scrollIntoView({ block: 'nearest' })
}
}
closeOnOutsideClick(event) {
if (!this.element.contains(event.target) && this.isOpen) {
this.close()
}
}
handleKeydown(event) {
if (!this.isOpen) return
switch (event.key) {
case 'Escape':
event.preventDefault()
this.close()
this.buttonTarget.focus()
break
case 'ArrowDown':
event.preventDefault()
this.navigateOptions(1)
break
case 'ArrowUp':
event.preventDefault()
this.navigateOptions(-1)
break
case 'Enter':
event.preventDefault()
const focused = this.dropdownTarget.querySelector('[data-select-target="option"]:focus')
if (focused) {
focused.click()
}
break
}
}
navigateOptions(direction) {
const visibleOptions = this.optionTargets.filter(option => !option.classList.contains('hidden'))
const currentIndex = visibleOptions.findIndex(option => option === document.activeElement)
let nextIndex
if (currentIndex === -1) {
nextIndex = direction > 0 ? 0 : visibleOptions.length - 1
} else {
nextIndex = currentIndex + direction
if (nextIndex < 0) nextIndex = visibleOptions.length - 1
if (nextIndex >= visibleOptions.length) nextIndex = 0
}
if (visibleOptions[nextIndex]) {
visibleOptions[nextIndex].focus()
}
}
dispatchChangeEvent() {
const event = new CustomEvent('select:change', {
detail: {
value: this.selectedValues,
multiple: this.multipleValue
}
})
this.element.dispatchEvent(event)
}
}
Installation & Usage
1
Copy the ERB template
Copy the ERB code from the template tab above and paste it into your Rails view file.
2
Add the Stimulus controller
Create the Stimulus controller file in your JavaScript controllers directory.