Combobox Rails Components
Advanced select components with search, multi-select, and custom rendering capabilities. Built with TomSelect and Stimulus.
Installation
1. Stimulus Controller Setup
Add the following controller to your project:
import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
export default class extends Controller {
static values = {
url: String, // URL to fetch options from
valueField: { type: String, default: "value" }, // Field to use for value
labelField: { type: String, default: "label" }, // Field to use for label
submitOnChange: { type: Boolean, default: false }, // Submit form on change
disableDropdownInput: { type: Boolean, default: false }, // Disable dropdown input
disableTyping: { type: Boolean, default: false }, // Disable typing
allowNew: { type: Boolean, default: false }, // Allow new options
scrollButtons: { type: Boolean, default: false }, // Show scroll buttons
updateField: { type: Boolean, default: false }, // Update field with selected value
updateFieldTarget: String, // Target to update
updateFieldSource: { type: String, default: "name" }, // Source to update
perPage: { type: Number, default: 60 }, // Number of options per page
virtualScroll: { type: Boolean, default: false }, // Use virtual scroll
optgroupColumns: { type: Boolean, default: false }, // Use optgroup columns
responseDataField: { type: String, default: "data" }, // Field in response containing array of items (auto-detects common patterns)
searchParam: { type: String, default: "query" }, // Search parameter name for the API
// New flexible rendering options
imageField: String, // Field containing image URL
subtitleField: String, // Field to show as subtitle
metaFields: String, // Comma-separated fields to show as metadata (e.g., "status,species")
badgeField: String, // Field to show as a badge/tag
renderTemplate: String, // Custom template for option rendering
// New count display options
showCount: { type: Boolean, default: false }, // Show count instead of individual items
countText: { type: String, default: "selected" }, // Text to show after count
countTextSingular: { type: String, default: "" }, // Text to show when only one item is selected (optional)
};
connect() {
if (this.element.tomselect) return;
const options = this.#buildOptions();
this.select = new TomSelect(this.element, options);
this.#setupEventHandlers();
this.#setupPositioning();
this.#handleInitialValue();
this.element.style.visibility = "visible";
}
disconnect() {
this.#cleanup();
}
// Private methods
#buildOptions() {
const plugins = this.#getPlugins();
const baseOptions = {
plugins,
maxOptions: null,
closeAfterSelect: !this.element.multiple,
create: this.allowNewValue,
render: this.#getRenderConfig(),
onChange: this.#handleChange.bind(this),
onDropdownOpen: () => this.#updatePosition(),
};
if (!this.hasUrlValue) return baseOptions;
return {
...baseOptions,
preload: true,
...(this.virtualScrollValue ? this.#getVirtualScrollConfig() : this.#getCustomScrollConfig()),
};
}
#getPlugins() {
const plugins = [];
const isMultiple = this.element.multiple;
const useVirtualScroll = this.virtualScrollValue && this.hasUrlValue;
if (useVirtualScroll) {
plugins.push("virtual_scroll");
if (isMultiple) plugins.push("remove_button", "checkbox_options", "no_active_items");
} else if (isMultiple) {
plugins.push("remove_button", "checkbox_options", "drag_drop", "no_active_items");
}
if (this.optgroupColumnsValue) plugins.push("optgroup_columns");
return plugins;
}
#getRenderConfig() {
const renderOption = (data, escape) => {
if (this.renderTemplateValue) return this.#renderWithTemplate(this.renderTemplateValue, data, escape);
if (this.hasUrlValue && this.#hasCustomFields()) return this.#renderApiOption(data, escape);
return this.#renderStandardOption(data, escape);
};
const renderItem = (data, escape) => {
if (this.hasUrlValue && this.imageFieldValue && data[this.imageFieldValue]) {
return this.#renderImageItem(data, escape);
}
return this.#renderStandardItem(data, escape);
};
return {
option: renderOption,
item: renderItem,
option_create: (data, escape) => `<div class="create">Add <strong>${escape(data.input)}</strong>…</div>`,
loading_more: () => this.#renderLoadingMore(),
no_more_results: () =>
`<div class="no-more-results hidden py-2 text-center text-sm text-neutral-500 dark:text-neutral-400">No more results</div>`,
};
}
#getVirtualScrollConfig() {
return {
valueField: this.valueFieldValue,
labelField: this.labelFieldValue,
searchField: this.labelFieldValue,
firstUrl: (query) => this.#buildApiUrl(this.urlValue, query, 1),
load: this.#virtualScrollLoad.bind(this),
shouldLoadMore: this.#shouldLoadMore.bind(this),
};
}
#getCustomScrollConfig() {
return {
valueField: this.valueFieldValue,
labelField: this.labelFieldValue,
searchField: this.labelFieldValue,
load: this.#customScrollLoad.bind(this),
};
}
async #virtualScrollLoad(query, callback) {
const url = this.select.getUrl(query);
const scrollState = this.#captureScrollState(url);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
const rawJson = await response.json();
const json = this.#transformApiResponse(rawJson);
this.#updateVirtualScrollState(url, query, json);
callback(json.data);
// For pages after the first, just maintain scroll position
if (scrollState.currentPage > 1) {
requestAnimationFrame(() => {
if (this.select?.dropdown_content && typeof scrollState.scrollTop === "number") {
this.select.dropdown_content.scrollTop = scrollState.scrollTop;
}
});
} else {
requestAnimationFrame(() => {
this.#restoreScrollState(scrollState);
this.#handlePostLoadFocus(query, scrollState);
});
}
} catch (error) {
console.error("Virtual scroll load error:", error);
this.select?.setNextUrl(query, null);
callback();
} finally {
this.#cleanupScrollState();
}
}
async #customScrollLoad(query, callback) {
this.#resetPagination();
try {
const response = await this.#fetchPage(query, 1);
const json = this.#transformApiResponse(response);
callback(json.data);
this.hasMore = json.has_more;
if (this.select?.dropdown_content) {
this.#setupInfiniteScroll();
setTimeout(() => this.#focusFirstOption(query), 10);
}
} catch (error) {
console.error("Custom scroll load error:", error);
callback();
this.hasMore = false;
}
}
#setupEventHandlers() {
// Override setActiveOption for single active state
const original = this.select.setActiveOption.bind(this.select);
this.select.setActiveOption = (option, scroll) => {
this.#clearAllActiveStates();
return original(option, scroll);
};
// Clear options if URL-based
if (this.hasUrlValue) this.select.clearOptions();
// Dropdown open handler
this.select.on("dropdown_open", () => this.#handleDropdownOpen());
// Setup additional features
if (this.scrollButtonsValue && this.select.dropdown_content) this.#addScrollButtons();
if (this.element.multiple) this.#setupScrollTracking();
if (this.disableTypingValue) this.#setupReadonlyInput();
// Setup count display for multi-select
if (this.element.multiple && this.showCountValue) {
this.#setupCountDisplay();
}
}
#setupPositioning() {
this.scrollHandler = () => this.#updatePosition();
window.addEventListener("scroll", this.scrollHandler, true);
this.resizeObserver = new ResizeObserver(() => this.#updatePosition());
this.resizeObserver.observe(document.documentElement);
this.mutationObserver = new MutationObserver(() => {
if (this.select?.dropdown?.classList.contains("ts-dropdown")) {
this.#updatePosition();
}
});
this.mutationObserver.observe(document.body, { childList: true, subtree: true });
}
#handleDropdownOpen() {
this.#clearAllActiveStates();
this.select.setActiveOption(null);
// Update position multiple times to ensure proper placement
[0, 10, 50, 100].forEach((delay) => {
setTimeout(() => this.#updatePosition(), delay);
});
if (this.hasUrlValue && !this.virtualScrollValue) {
this.#setupInfiniteScroll();
this.#resetPagination();
}
}
#setupInfiniteScroll() {
const content = this.select.dropdown_content;
if (!content) return;
const handler = this.#handleScroll.bind(this);
content.removeEventListener("scroll", handler);
content.addEventListener("scroll", handler);
}
#handleScroll() {
if (this.virtualScrollValue || !this.select?.dropdown_content) return;
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
if (scrollTop + clientHeight >= scrollHeight - 50) {
const query = this.select.control_input?.value || "";
this.#loadMore(query);
}
}
async #loadMore(query) {
if (this.virtualScrollValue || this.loadingMore || !this.hasMore) return;
this.loadingMore = true;
this.currentPage += 1;
const lastActiveValue = this.#getActiveValue();
try {
const response = await this.#fetchPage(query, this.currentPage);
const newOptions = this.#transformApiResponse(response);
if (newOptions?.data?.length > 0) {
this.select.addOptions(newOptions.data);
this.hasMore = newOptions.has_more;
setTimeout(() => this.#restoreSelectionAfterLoading(lastActiveValue), 300);
} else {
this.hasMore = false;
}
this.select.control_input?.focus();
} catch (error) {
console.error("Load more error:", error);
this.hasMore = false;
} finally {
this.loadingMore = false;
this.#updatePosition();
}
}
#setupScrollTracking() {
const content = this.select.dropdown_content;
if (!content) return;
content.addEventListener("scroll", () => {
this.lastScrollPosition = content.scrollTop;
});
["item_add", "item_remove"].forEach((event) => {
this.select.on(event, () => {
if (this.lastScrollPosition) {
setTimeout(() => {
content.scrollTop = this.lastScrollPosition;
}, 0);
}
});
});
}
#setupReadonlyInput() {
const input = this.select.control_input;
if (!input) return;
input.readOnly = true;
input.setAttribute("readonly", "readonly");
let buffer = "";
let timeout;
input.addEventListener("keydown", (e) => {
if (!this.#isNavigationKey(e.key)) return;
if (e.key.length === 1) {
document.body.requestPointerLock();
this.#handleTypeAhead(e.key, buffer, timeout);
}
});
document.addEventListener("mousemove", () => {
if (document.pointerLockElement) document.exitPointerLock();
});
}
#setupCountDisplay() {
// Create count element
this.countElement = document.createElement("div");
this.countElement.className = "ts-count-display";
// Insert count element into the control
this.select.control.appendChild(this.countElement);
// Update count on initial load
this.#updateCountDisplay();
// Listen for changes and prevent dropdown from closing
this.select.on("item_add", () => {
this.#updateCountDisplay();
// Force dropdown to stay open after selection
setTimeout(() => {
if (!this.select.isOpen) {
this.select.open();
}
}, 0);
});
this.select.on("item_remove", () => this.#updateCountDisplay());
}
#updateCountDisplay() {
const count = Object.keys(this.select.getValue()).length;
if (count > 0) {
// Use singular text if provided and count is 1, otherwise use regular countText
const textToUse = count === 1 && this.countTextSingularValue ? this.countTextSingularValue : this.countTextValue;
this.countElement.textContent = `${count} ${textToUse}`;
this.select.control.classList.add("count-active");
} else {
this.select.control.classList.remove("count-active");
}
}
#handleTypeAhead(key, buffer, timeout) {
clearTimeout(timeout);
timeout = setTimeout(() => {
buffer = "";
}, 1000);
buffer += key.toLowerCase();
const match = this.#findMatchingOption(buffer);
if (match) {
const optionEl = this.select.dropdown_content.querySelector(`[data-value="${match[this.valueFieldValue]}"]`);
if (optionEl) {
this.select.setActiveOption(optionEl);
this.select.open();
this.#scrollToOption(optionEl);
}
}
}
#addScrollButtons() {
const createButton = (direction, position) => {
const btn = document.createElement("div");
btn.className = `absolute left-0 right-0 ${position} h-5 bg-gradient-to-${
direction === "up" ? "b" : "t"
} from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150`;
btn.innerHTML = `<svg class="size-3 text-neutral-600 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M${
direction === "up" ? "5 15l7-7 7 7" : "19 9l-7 7-7-7"
}"></path></svg>`;
return btn;
};
const scrollUpBtn = createButton("up", "top-0");
const scrollDownBtn = createButton("down", "bottom-0");
let scrollInterval;
const scrollSpeed = 80;
const setupScrollButton = (btn, direction) => {
const startScroll = () => {
if (scrollInterval) clearInterval(scrollInterval);
scrollInterval = setInterval(() => {
this.select.dropdown_content.scrollTop += direction === "up" ? -scrollSpeed : scrollSpeed;
}, 100);
btn.style.opacity = "0.7";
};
const stopScroll = () => {
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = null;
}
btn.style.opacity = "1";
};
// Mouse events
btn.addEventListener("mouseenter", startScroll);
btn.addEventListener("mouseleave", stopScroll);
// Touch events
["touchstart", "touchend", "touchcancel"].forEach((event) => {
btn.addEventListener(
event,
(e) => {
e.preventDefault();
event === "touchstart" ? startScroll() : stopScroll();
},
{ passive: false }
);
});
};
setupScrollButton(scrollUpBtn, "up");
setupScrollButton(scrollDownBtn, "down");
this.select.dropdown.insertBefore(scrollUpBtn, this.select.dropdown.firstChild);
this.select.dropdown.appendChild(scrollDownBtn);
// Show/hide based on scroll
this.select.dropdown_content.addEventListener("scroll", () => {
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
scrollUpBtn.style.display = scrollTop > 0 ? "flex" : "none";
scrollDownBtn.style.display = scrollTop + clientHeight < scrollHeight ? "flex" : "none";
});
scrollUpBtn.style.display = "none";
}
async #updatePosition() {
if (!this.select?.dropdown) return;
// Don't reposition during infinite scroll loading
if (this.select.dropdown_content?.classList.contains("is-loading-more")) return;
const reference = this.select.control;
const floating = this.select.dropdown;
if (!reference.getBoundingClientRect().height || !floating.getBoundingClientRect().height) {
if (floating.offsetParent !== null) {
setTimeout(() => this.#updatePosition(), 50);
}
return;
}
try {
const { x, y } = await computePosition(reference, floating, {
placement: "bottom-start",
middleware: [offset(6), flip(), shift({ padding: 8 })],
});
Object.assign(floating.style, {
position: "absolute",
left: `${x}px`,
top: `${y}px`,
width: `${Math.max(reference.offsetWidth, 160)}px`,
});
} catch (error) {
console.warn("Position update error:", error);
}
}
#handleChange(value) {
if (value === "none") {
this.element.value = "";
if (this.submitOnChangeValue) {
const url = new URL(window.location.href);
url.searchParams.delete(this.element.name);
window.location.href = url.toString();
}
} else {
if (this.submitOnChangeValue) {
this.element.form.requestSubmit();
this.element.value = value;
this.#addSpinner();
}
if (this.updateFieldValue) {
this.#updateTargetField(value);
}
}
}
#updateTargetField(value) {
const form = this.element.closest("form");
if (!form) return;
const targetField = this.updateFieldTargetValue
? form.querySelector(this.updateFieldTargetValue)
: form.querySelector('input[name="list_contact[name]"]');
if (!targetField) return;
const selectedOption = this.select.options[value];
if (!selectedOption) return;
const data = this.#parseOptionData(selectedOption);
if (data?.[this.updateFieldSourceValue]) {
targetField.value = data[this.updateFieldSourceValue];
targetField.dispatchEvent(new Event("input", { bubbles: true }));
}
}
#parseOptionData(option) {
if (typeof option.text === "string" && option.text.startsWith("{")) {
try {
return JSON.parse(option.text);
} catch (e) {
console.warn("Parse error:", e);
}
}
return null;
}
#addSpinner() {
const container = this.element.closest(".relative")?.querySelector(".absolute.z-10");
if (container) {
container.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin size-7 mr-[5px] text-neutral-500 p-1 rounded-full bg-white dark:bg-neutral-700" width="24" height="24" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
`;
}
}
#transformApiResponse(response) {
const data = this.#extractDataArray(response);
const transformedData = (data || []).map((item) => ({
...item,
text: item.text || item[this.labelFieldValue] || item.name || "",
value: item.value || item[this.valueFieldValue],
}));
if (!this.virtualScrollValue) {
const hasMore = this.#detectHasMore(response, data);
return { data: transformedData, has_more: hasMore };
}
return { data: transformedData };
}
#extractDataArray(response) {
if (this.responseDataFieldValue !== "data") {
return this.#getNestedValue(response, this.responseDataFieldValue);
}
if (Array.isArray(response)) return response;
const fields = ["data", "results", "items"];
for (const field of fields) {
if (response[field] && Array.isArray(response[field])) {
return response[field];
}
}
return null;
}
#detectHasMore(response, data) {
return (
response.has_more ||
response.hasMore ||
!!response.next ||
!!response.next_page_url ||
(response.info && !!response.info.next) ||
(data && data.length === this.perPageValue) ||
false
);
}
#buildApiUrl(baseUrl, query, page) {
const url = new URL(baseUrl, window.location.origin);
if (query) url.searchParams.set(this.searchParamValue, query);
url.searchParams.set("page", page);
const isExternalApi = !baseUrl.startsWith("/") && !baseUrl.startsWith(window.location.origin);
if (!isExternalApi) {
url.searchParams.set("per_page", this.perPageValue);
}
return url.toString();
}
async #fetchPage(query, page) {
const url = this.#buildApiUrl(this.urlValue, query, page);
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
return response.json();
}
#renderStandardOption(data, escape) {
const optionData = this.#parseOptionData(data);
if (optionData) {
return `<div class='flex items-center gap-y-[3px] gap-x-1.5 flex-wrap'>
${optionData.icon || ""}
<span>${escape(optionData.name)}</span>
${optionData.side || ""}
${
optionData.description
? `<p class='text-neutral-500 dark:text-neutral-300 text-xs my-0 w-full'>${escape(
optionData.description
)}</p>`
: ""
}
</div>`;
}
return `<div class='flex items-center gap-1.5'><span>${escape(data.text)}</span></div>`;
}
#renderStandardItem(data, escape) {
const optionData = this.#parseOptionData(data);
if (optionData) {
return `<div class='!flex items-center gap-1.5'>
${optionData.icon || ""}
<span>${escape(optionData.name)}</span>
</div>`;
}
return `<div class='!flex items-center gap-1.5'><span class='line-clamp-1'>${escape(data.text)}</span></div>`;
}
#renderImageItem(data, escape) {
const label = data[this.labelFieldValue] || data.name || data.text;
return `<div class='!flex items-center gap-2'>
<img class='size-5 rounded-full' src='${escape(data[this.imageFieldValue])}' alt='${escape(label)}'>
<span class='line-clamp-1'>${escape(label)}</span>
</div>`;
}
#renderApiOption(data, escape) {
const hasImage = this.imageFieldValue && data[this.imageFieldValue];
const label = data[this.labelFieldValue] || data.name || data.text;
let html = `<div class='${hasImage ? "flex items-start gap-3" : ""} py-1'>`;
if (hasImage) {
html += `<img class='size-10 rounded-full flex-shrink-0' src='${escape(
data[this.imageFieldValue]
)}' alt='${escape(label)}'>`;
html += `<div class='flex-1 min-w-0'>`;
}
html += `<div class='font-medium'>${escape(label)}</div>`;
if (this.subtitleFieldValue && data[this.subtitleFieldValue]) {
html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${escape(data[this.subtitleFieldValue])}`;
if (this.badgeFieldValue && data[this.badgeFieldValue]) {
html += ` • ${escape(data[this.badgeFieldValue])}`;
}
html += `</div>`;
}
if (this.metaFieldsValue) {
const metaValues = this.metaFieldsValue
.split(",")
.map((f) => f.trim())
.filter((field) => data[field])
.map((field) => escape(data[field]));
if (metaValues.length > 0) {
html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${metaValues.join(" • ")}</div>`;
}
}
if (hasImage) html += `</div>`;
html += `</div>`;
return html;
}
#renderWithTemplate(template, data, escape) {
return template.replace(/\{\{(\w+)\}\}/g, (match, field) => (data[field] ? escape(data[field]) : ""));
}
#renderLoadingMore() {
return `<div class="loading-more-results py-2 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
<svg class="animate-spin -ml-1 mr-3 h-4 w-4 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>Loading...
</div>`;
}
// Helper methods
#hasCustomFields() {
return this.imageFieldValue || this.subtitleFieldValue || this.metaFieldsValue;
}
#isNavigationKey(key) {
return (
(key.length === 1 && key.match(/[a-z0-9]/i)) || ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)
);
}
#getNestedValue(obj, path) {
return path.split(".").reduce((current, key) => current?.[key], obj);
}
#clearAllActiveStates() {
if (!this.select?.dropdown_content) return;
// Clear both regular options and create option
this.select.dropdown_content.querySelectorAll(".option.active, .create.active").forEach((opt) => {
opt.classList.remove("active");
opt.setAttribute("aria-selected", "false");
});
if (this.select.activeOption) {
this.select.activeOption = null;
}
}
#captureScrollState(url) {
const currentUrl = new URL(url, window.location.origin);
const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
let state = { currentPage };
if (this.select?.dropdown_content) {
state.scrollTop = this.select.dropdown_content.scrollTop;
state.scrollHandler = this.select.dropdown_content.onscroll;
if (currentPage > 1) {
const activeItem = this.select.dropdown_content.querySelector(".option.active");
if (activeItem) {
state.lastActiveValue = activeItem.getAttribute("data-value");
}
}
this.select.dropdown_content.onscroll = null;
this.select.dropdown_content.classList.add("is-loading-more");
}
return state;
}
#restoreScrollState(state) {
if (!this.select?.dropdown_content || !state) return;
if (typeof state.scrollTop === "number") {
this.select.dropdown_content.scrollTop = state.scrollTop;
}
this.select.dropdown_content.onscroll = state.scrollHandler;
}
#cleanupScrollState() {
if (this.select?.dropdown_content) {
this.select.dropdown_content.classList.remove("is-loading-more");
}
}
#updateVirtualScrollState(url, query, json) {
const currentUrl = new URL(url, window.location.origin);
const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
const hasMore = json.data.length === this.perPageValue || json.info?.next || json.next || json.has_more;
if (hasMore) {
const nextUrl = this.#buildApiUrl(this.urlValue, query, currentPage + 1);
this.select.setNextUrl(query, nextUrl);
} else {
this.select.setNextUrl(query, null);
}
}
#handlePostLoadFocus(query, scrollState) {
if (!this.select?.dropdown_content) return;
// Don't mess with focus/selection during infinite scroll
if (scrollState.currentPage > 1) {
// Just maintain the current scroll position
return;
}
this.#clearAllActiveStates();
this.select.setActiveOption(null);
if (scrollState.currentPage === 1) {
this.#focusFirstOption(query);
}
}
#focusFirstOption(query) {
if (!query?.trim() || !this.select?.dropdown_content) return;
const currentActive = this.select.dropdown_content.querySelector(".option.active");
if (currentActive || this.select.activeOption) return;
const firstOption = this.select.dropdown_content.querySelector(".option:not(.create):not(.no-results)");
if (firstOption) {
this.select.setActiveOption(firstOption);
}
}
#restoreSelectionAfterLoading(lastActiveValue) {
if (!this.select?.dropdown_content || !lastActiveValue) return;
const currentActive = this.select.dropdown_content.querySelector(".option.active");
if (currentActive) return;
const itemToRestore = this.select.dropdown_content.querySelector(`[data-value="${lastActiveValue}"]`);
if (itemToRestore) {
this.select.setActiveOption(itemToRestore);
}
}
#getActiveValue() {
const activeItem = this.select?.dropdown_content?.querySelector(".option.active");
return activeItem?.getAttribute("data-value");
}
#findMatchingOption(buffer) {
return Object.values(this.select.options).find((option) => {
const label = this.hasUrlValue
? option[this.labelFieldValue]
: this.#parseOptionData(option)?.name || option.text;
return label.toLowerCase().startsWith(buffer);
});
}
#scrollToOption(optionEl) {
const content = this.select.dropdown_content;
const dropdownHeight = content.offsetHeight;
const optionTop = optionEl.offsetTop;
const optionHeight = optionEl.offsetHeight;
if (optionTop < content.scrollTop) {
content.scrollTop = optionTop;
} else if (optionTop + optionHeight > content.scrollTop + dropdownHeight) {
content.scrollTop = optionTop + optionHeight - dropdownHeight;
}
}
#resetPagination() {
this.currentPage = 1;
this.hasMore = true;
this.loadingMore = false;
}
#shouldLoadMore() {
if (!this.select?.dropdown_content) return false;
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
return scrollTop + clientHeight + 150 >= scrollHeight;
}
#handleInitialValue() {
if (!this.updateFieldValue || !this.hasUrlValue) return;
try {
const currentValue = this.getValue(this.urlValue);
if (currentValue) {
this.select.setValue(currentValue);
}
} catch (error) {
console.warn("Initial value setting skipped");
}
// Re-init with dropdown_input if needed
if (!this.disableDropdownInputValue && this.select.getValue()) {
const options = this.#buildOptions();
options.plugins.push("dropdown_input");
this.select.destroy();
this.select = new TomSelect(this.element, options);
this.#setupEventHandlers();
}
}
#cleanup() {
if (this.checkboxObserver) this.checkboxObserver.disconnect();
if (this.select) {
this.select.destroy();
this.select = null;
}
window.removeEventListener("scroll", this.scrollHandler, true);
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.mutationObserver) this.mutationObserver.disconnect();
}
}
2. Dependencies Installation
This component relies on Floating UI & Tom Select for the combobox functionality. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.0/+esm"
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.4.3/+esm"
npm install @floating-ui/dom
npm install tom-select
yarn add @floating-ui/dom
yarn add tom-select
Now add this to your <head>
HTML tag:
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.css" rel="stylesheet">
3. CSS Styles
Here is the custom CSS I've used to style the combobox:
/* Tom Select */
select[multiple][data-controller="select"] {
@apply invisible;
}
.dropdown-input {
@apply !border-neutral-300 !bg-white !px-3 !py-2.5 text-sm placeholder:!text-neutral-500 dark:!border-neutral-600 dark:!bg-neutral-700 dark:!placeholder-neutral-300;
}
.plugin-dropdown_input.focus.dropdown-active .ts-control {
@apply !border-none;
}
.ts-dropdown-content {
@apply py-1.5;
max-height: 240px;
}
.ts-dropdown-content {
scrollbar-width: thin;
scrollbar-color: #a2a2a270 #7878780b;
}
.ts-dropdown-content::-webkit-scrollbar {
width: 6px;
}
.ts-dropdown-content::-webkit-scrollbar-track {
background: #78787879;
}
.ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: #a2a2a270;
border-radius: 3px;
}
.ts-control {
@apply flex min-h-[40px] w-full px-3 py-2 cursor-default rounded-lg border-0 text-base leading-6 text-neutral-900 shadow-sm ring-1 placeholder:text-neutral-400 ring-neutral-300 outline-hidden ring-inset focus:ring-neutral-600 dark:bg-neutral-700 dark:text-white dark:placeholder-neutral-300 dark:ring-neutral-600 dark:focus:ring-neutral-500;
&[disabled] {
@apply cursor-not-allowed bg-neutral-100 dark:bg-neutral-600;
}
&.error {
@apply border-red-400 outline-red-300 focus:outline-red-500 dark:border-red-600 dark:outline-red-500;
}
}
.plugin-dropdown_input .dropdown-input {
@apply outline-hidden;
}
.ts-dropdown .active.create {
@apply cursor-pointer bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}
.loading-more-results {
@apply !cursor-default;
}
.disabled .ts-control {
cursor: not-allowed !important;
}
@media (min-width: 640px) {
.ts-control {
font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
}
}
.full .ts-control {
@apply dark:bg-neutral-700;
}
.ts-wrapper.single .ts-control,
.ts-wrapper.single .ts-control input,
.ts-control,
.ts-wrapper.single.input-active .ts-control {
@apply cursor-text;
}
.ts-dropdown [data-selectable] .highlight {
@apply bg-orange-500/20 dark:bg-yellow-500/20;
}
.ts-control,
.ts-wrapper.single.input-active .ts-control {
@apply bg-white dark:bg-neutral-700;
}
.input-active {
@apply rounded-lg bg-transparent outline-2 outline-neutral-600 dark:bg-neutral-600 dark:outline-neutral-500;
}
.ts-control input {
@apply !m-0 bg-white text-base placeholder:text-neutral-400 read-only:!cursor-pointer dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-300;
}
@media (min-width: 640px) {
.ts-control input {
font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
}
}
.ts-wrapper:not(trix-toolbar .trix-input--dialog):not(.form-select).single .ts-control {
@apply !pr-8;
}
.ts-wrapper.plugin-remove_button .item {
@apply rounded-md;
}
.ts-wrapper.plugin-remove_button .item .remove {
@apply rounded-r-lg border-none py-1 text-lg leading-none;
}
.ts-wrapper.plugin-remove_button .item .remove::before {
content: "";
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%236B7280'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
@apply block size-4 bg-center bg-no-repeat;
}
/* Add separate dark mode version */
.dark {
.ts-wrapper.plugin-remove_button .item .remove::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%239CA3AF'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
}
}
.ts-wrapper.plugin-remove_button .item .remove {
font-size: 0 !important;
@apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded !border-0 !p-1 !leading-none text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200;
}
.ts-dropdown {
@apply z-40 m-0 overflow-hidden rounded-lg border border-t border-solid border-neutral-300 shadow-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white;
}
.ts-dropdown .create {
@apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm dark:text-neutral-400;
}
.ts-dropdown [data-selectable].option,
.ts-dropdown .no-results {
@apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm;
}
.ts-dropdown .option,
.ts-dropdown [data-disabled],
.ts-dropdown [data-disabled] [data-selectable].option {
@apply mx-1.5 cursor-not-allowed rounded-md px-2.5 py-2 text-sm;
}
.ts-dropdown [data-selectable].option,
.ts-dropdown .ts-dropdown .create {
@apply cursor-pointer;
}
.ts-dropdown .active {
@apply bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}
.ts-dropdown .spinner {
@apply h-auto w-auto;
}
.ts-dropdown .spinner:after {
@apply mt-1 mb-0 inline-block size-4 border-2 p-0;
}
.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
print-color-adjust: exact;
}
/* Dark mode arrow for single select */
.dark {
.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
}
/* Add dropdown arrow to multiselect elements */
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.6rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
print-color-adjust: exact;
padding-right: 2rem !important;
}
/* Dark mode arrow for multiselect */
.dark {
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
}
}
.ts-wrapper.multi .ts-control > div {
@apply mr-1 inline-flex items-center justify-center rounded-md bg-neutral-100 px-2 text-xs leading-none font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100;
}
/* Ensure items don't overlap with the dropdown arrow */
.ts-wrapper.multi.has-items .ts-control {
@apply !pt-[7px] !pr-8 !pb-[4px];
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item {
@apply cursor-grab;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
@apply !-ml-0.5 cursor-pointer border-none;
}
.ts-wrapper.plugin-remove_button .item .remove {
@apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded border-0 text-lg leading-none text-neutral-900/60 hover:text-neutral-900 dark:text-neutral-100/60 dark:hover:bg-neutral-700 dark:hover:text-neutral-100;
}
.ts-dropdown .optgroup-header {
@apply border-t border-neutral-300 bg-white font-semibold text-neutral-900 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100;
}
.ts-dropdown.plugin-optgroup_columns .optgroup {
height: fit-content;
@apply !mt-0;
}
.optgroup {
@apply mt-1.5 first:mt-0;
}
.dark .ts-dropdown.plugin-optgroup_columns .optgroup {
border-right: 1px solid #525252;
}
.ts-wrapper.multi.has-items .ts-control > input {
@apply !mb-[3px];
}
.tomselect-checkbox {
@apply !mr-0;
}
.input-hidden.focus {
@apply !rounded-lg border border-neutral-300 dark:border-neutral-600;
}
/* Replace the previous attempt with this updated selector */
select[data-select-disable-typing-value="true"] + .ts-wrapper .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control input,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single.input-active .ts-control {
@apply cursor-default;
}
.ts-dropdown-content.is-loading-more .option {
pointer-events: none !important;
}
/* Count display for multi-select */
.ts-count-display {
@apply mr-auto !my-0.5 !bg-transparent !px-0 !text-sm !font-normal;
display: none;
}
/* Hide count display when not active (explicit rule) */
.ts-control:not(.count-active) .ts-count-display {
display: none !important;
}
/* Hide items and input when count is active */
.ts-control.count-active .item {
display: none !important;
visibility: hidden !important;
width: 0 !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
/* Keep input technically visible for keyboard navigation but make it invisible */
.ts-control.count-active input {
position: absolute !important;
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
/* Ensure proper spacing when count is displayed */
.ts-wrapper.multi.has-items .ts-control:has(.ts-count-display) {
@apply !py-[5px];
}
Examples
Basic Combobox
A simple combobox with search functionality.
<div class="w-full max-w-xs">
<%= select_tag :framework,
options_for_select([
["Ruby on Rails", "rails"],
["Laravel", "laravel"],
["Django", "django"],
["Express.js", "express"],
["Spring Boot", "spring"],
["ASP.NET Core", "aspnet"],
["Phoenix", "phoenix"],
["FastAPI", "fastapi"]
]),
include_blank: "Select framework...",
class: "w-full",
data: {
controller: "select"
} %>
</div>
Combobox with Icons and Descriptions
Enhanced combobox with icons, badges, and descriptions for each option.
<div class="w-full max-w-md">
<%= select_tag :framework_with_details,
options_for_select([
[{
icon: "<img class='size-5 my-0 dark:invert' src='https://cdn.prod.website-files.com/61d14fa88c31332eb4187c19/62af9b63d93f763db776358d_Tech-Rails-Black.png'>",
name: "Rails",
side: "<span class='ml-auto text-xs text-red-600 dark:text-red-400'>Ruby</span>",
description: "Full-stack web application framework with convention over configuration"
}.to_json, "rails"],
[{
icon: "<img class='size-5 my-0 dark:invert' src='https://cdn.prod.website-files.com/61d14fa88c31332eb4187c19/62af928c450de106001471fd_Tech-Laravel-Black.png'>",
name: "Laravel",
side: "<span class='ml-auto text-xs text-green-600 dark:text-green-400'>PHP</span>",
description: "Elegant PHP framework with expressive, beautiful syntax"
}.to_json, "laravel"],
[{
icon: "<img class='size-5 my-0 dark:invert' src='https://cdn.prod.website-files.com/61d14fa88c31332eb4187c19/62af943503138202b88a1ef6_Tech-Django-Black.png'>",
name: "Django",
side: "<span class='ml-auto text-xs text-blue-600 dark:text-blue-400'>Python</span>",
description: "High-level Python web framework for rapid development"
}.to_json, "django"],
[{
icon: "<svg class='size-5 my-0' viewBox='0 0 24 24' fill='currentColor'><path d='M24 18.588a1.529 1.529 0 01-1.895-.72l-3.45-4.771-.5-.667-4.003 5.444a1.466 1.466 0 01-1.802.708l5.158-6.92-4.798-6.251a1.595 1.595 0 011.9.666l3.576 4.83 3.596-4.81a1.435 1.435 0 011.788-.668L21.708 7.9l-2.522 3.283a.666.666 0 000 .994l4.804 6.412zM.002 11.576l.42-2.075c1.154-4.103 5.858-5.81 9.094-3.27 1.895 1.489 2.368 3.597 2.275 5.973H1.116C.943 16.447 4.005 19.009 7.92 17.7a4.078 4.078 0 002.582-2.876c.207-.666.548-.78 1.174-.588a5.417 5.417 0 01-2.589 3.957 6.272 6.272 0 01-7.306-.933 6.575 6.575 0 01-1.64-3.858c0-.235-.08-.455-.134-.666A88.33 88.33 0 010 11.577zm1.127-.286h9.654c-.06-3.076-2.001-5.258-4.59-5.278-2.882-.04-4.944 2.094-5.071 5.264z'/></svg>",
name: "Express.js",
side: "<span class='ml-auto text-xs text-yellow-600 dark:text-yellow-400'>Node.js</span>",
description: "Fast, unopinionated, minimalist web framework for Node.js"
}.to_json, "express"]
]),
include_blank: "Select framework...",
class: "w-full",
data: {
controller: "select"
} %>
</div>
Multi-select Combobox
Allow users to select multiple options with checkboxes and tags.
<div class="w-full max-w-md">
<%= select_tag :programming_languages,
options_for_select([
["Ruby", "ruby"],
["JavaScript", "javascript"],
["Python", "python"],
["TypeScript", "typescript"],
["Go", "go"],
["Rust", "rust"],
["Java", "java"],
["C#", "csharp"],
["PHP", "php"],
["Swift", "swift"],
["Kotlin", "kotlin"],
["Scala", "scala"]
]),
multiple: true,
include_blank: "Select languages...",
class: "w-full",
data: {
controller: "select"
} %>
</div>
Count Display for Multi-select
Display a count of selected items instead of individual tags. Perfect for filter interfaces where space is limited or when you have many selectable options. Supports singular/plural text variations for better grammar.
Select multiple categories to filter products
This example shows pre-selected items with count display
Works with grouped options too
<div class="space-y-6 w-full max-w-md">
<div>
<label for="filter-categories" class="block text-sm font-medium mb-1">Filter by Categories</label>
<select id="filter-categories"
name="categories[]"
multiple
class="w-full"
data-controller="select"
data-select-show-count-value="true"
data-select-count-text-value="categories selected"
data-select-count-text-singular-value="category selected">
<option value="">Select categories...</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing & Apparel</option>
<option value="home">Home & Garden</option>
<option value="sports">Sports & Outdoors</option>
<option value="books">Books & Media</option>
<option value="toys">Toys & Games</option>
<option value="health">Health & Beauty</option>
<option value="automotive">Automotive</option>
<option value="food">Food & Grocery</option>
<option value="office">Office Supplies</option>
</select>
<p class="mt-0.5 text-xs text-neutral-600 dark:text-neutral-400">Select multiple categories to filter products</p>
</div>
<div>
<label for="filter-tags" class="block text-sm font-medium mb-1">Filter by Tags (Pre-selected)</label>
<select id="filter-tags"
name="tags[]"
multiple
class="w-full"
data-controller="select"
data-select-show-count-value="true"
data-select-count-text-value="tags selected"
data-select-count-text-singular-value="tag selected">
<option value="">Select tags...</option>
<option value="new" selected>New Arrival</option>
<option value="sale" selected>On Sale</option>
<option value="featured" selected>Featured</option>
<option value="limited">Limited Edition</option>
<option value="exclusive">Exclusive</option>
<option value="clearance">Clearance</option>
<option value="bestseller">Bestseller</option>
<option value="eco">Eco-Friendly</option>
<option value="premium">Premium</option>
<option value="budget">Budget-Friendly</option>
</select>
<p class="mt-0.5 text-xs text-neutral-600 dark:text-neutral-400">This example shows pre-selected items with count display</p>
</div>
<div>
<label for="filter-locations" class="block text-sm font-medium mb-1">Available Locations</label>
<select id="filter-locations"
name="locations[]"
multiple
class="w-full"
data-controller="select"
data-select-show-count-value="true"
data-select-count-text-value="locations"
data-select-count-text-singular-value="location">
<option value="">Select locations...</option>
<optgroup label="North America">
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="mx">Mexico</option>
</optgroup>
<optgroup label="Europe">
<option value="uk">United Kingdom</option>
<option value="de">Germany</option>
<option value="fr">France</option>
<option value="it">Italy</option>
<option value="es">Spain</option>
</optgroup>
<optgroup label="Asia Pacific">
<option value="jp">Japan</option>
<option value="cn">China</option>
<option value="au">Australia</option>
<option value="in">India</option>
</optgroup>
</select>
<p class="mt-0.5 text-xs text-neutral-600 dark:text-neutral-400">Works with grouped options too</p>
</div>
</div>
Grouped Options Combobox
Organize options into logical groups for better navigation.
<div class="w-full max-w-md">
<%= select_tag :technology_stack,
grouped_options_for_select({
"Frontend Frameworks" => [
["React", "react"],
["Vue.js", "vue"],
["Angular", "angular"],
["Svelte", "svelte"],
["Alpine.js", "alpine"]
],
"Backend Frameworks" => [
["Ruby on Rails", "rails"],
["Django", "django"],
["Express.js", "express"],
["Laravel", "laravel"],
["Spring Boot", "spring"]
],
"Mobile Development" => [
["React Native", "react-native"],
["Flutter", "flutter"],
["Swift", "swift"],
["Kotlin", "kotlin"],
["Ionic", "ionic"]
],
"Database Systems" => [
["PostgreSQL", "postgresql"],
["MySQL", "mysql"],
["MongoDB", "mongodb"],
["Redis", "redis"],
["SQLite", "sqlite"]
]
}),
include_blank: "Select technology...",
class: "w-full",
data: {
controller: "select"
} %>
</div>
Combobox with Create Option
Allow users to create new options on the fly.
Type to search or create a new tag
Select existing skills or type to add new ones
<div class="space-y-6">
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Single Select with Create</label>
<%= select_tag :custom_tags,
options_for_select([
["Bug", "bug"],
["Feature", "feature"],
["Enhancement", "enhancement"],
["Documentation", "documentation"],
["Question", "question"]
]),
include_blank: "Select or create tag...",
class: "w-full",
data: {
controller: "select",
select_allow_new_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">Type to search or create a new tag</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Multi-select with Create</label>
<%= select_tag :custom_skills,
options_for_select([
["JavaScript", "javascript"],
["Python", "python"],
["Ruby", "ruby"],
["Go", "go"],
["Rust", "rust"]
]),
multiple: true,
include_blank: "Select or add skills...",
class: "w-full",
data: {
controller: "select",
select_allow_new_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">Select existing skills or type to add new ones</p>
</div>
</div>
Disabled States
Examples of disabled comboboxes and disabled options.
This combobox is completely disabled
Some options are disabled and cannot be selected
Disabled options appear in different groups
<div class="space-y-6">
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Fully Disabled Combobox</label>
<%= select_tag :disabled_select,
options_for_select([
["Option 1", "1"],
["Option 2", "2"],
["Option 3", "3"]
], "2"),
include_blank: "Select option...",
class: "w-full",
disabled: true,
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">This combobox is completely disabled</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Combobox with Disabled Options</label>
<%= select_tag :partial_disabled,
options_for_select([
["Available Option 1", "1"],
["Unavailable Option", "2", { disabled: true }],
["Available Option 2", "3"],
["Out of Stock", "4", { disabled: true }],
["Available Option 3", "5"],
["Coming Soon", "6", { disabled: true }]
]),
include_blank: "Select available option...",
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Some options are disabled and cannot be selected</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Grouped Options with Disabled Items</label>
<%= select_tag :grouped_disabled,
grouped_options_for_select({
"In Stock" => [
["Product A", "a"],
["Product B", "b"],
["Product C", "c"]
],
"Out of Stock" => [
["Product D", "d", { disabled: true }],
["Product E", "e", { disabled: true }]
],
"Pre-order" => [
["Product F (Available)", "f"],
["Product G (Unavailable)", "g", { disabled: true }],
["Product H (Available)", "h"]
]
}),
include_blank: "Select product...",
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p>
</div>
</div>
Async Data Loading
Load options from a remote API with infinite scroll support. In this example, we'll be using the GitHub API to search for users.
⚠️ Important: Most External APIs Need a Backend Proxy
Many external APIs like GitHub (https://api.github.com
) cannot be called directly from the browser because:
- CORS restrictions: Most APIs don't allow browser requests from other domains
- Authentication: API keys shouldn't be exposed in frontend code
- Rate limiting: Direct browser requests can quickly hit rate limits
For these APIs, create a backend endpoint to proxy the requests. However, some APIs like the Rick and Morty API (see example below) do support CORS and can be called directly.
Live search with avatars, user/org tags, and profile links
<div class="space-y-6">
<div class="w-full">
<label class="block text-sm font-medium mb-1">Async GitHub Search</label>
<%= select_tag :async_users,
nil,
include_blank: "Search GitHub users...",
class: "w-full",
data: {
controller: "select",
select_url_value: "/api/github-users",
select_value_field_value: "login",
select_label_field_value: "name",
select_per_page_value: 20,
select_virtual_scroll_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">
Live search with avatars, user/org tags, and profile links
</p>
</div>
</div>
# Github API route for async loading
get "/api/github-users", to: "github_api#index"
class GithubApiController < ApplicationController
def index
query = params[:query] || ""
page = params[:page] || 1
per_page = params[:per_page] || 20
return render_empty_response if query.blank?
response = fetch_github_users(query, page, per_page)
render json: response
end
private
def render_empty_response
render json: {data: [], has_more: false}
end
def fetch_github_users(query, page, per_page)
response = call_github_api(query, page, per_page)
return {data: [], has_more: false} unless response
return {data: [], has_more: false} unless valid_response?(response)
users = transform_users(response["items"])
{
data: users,
has_more: response["total_count"] && response["total_count"] > (page.to_i * per_page.to_i)
}
rescue => e
Rails.logger.error "GitHub API request failed: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
{data: [], has_more: false}
end
def call_github_api(query, page, per_page)
response = HTTParty.get(
"https://api.github.com/search/users",
query: {q: query, page: page, per_page: per_page},
timeout: 10
)
unless response.success?
Rails.logger.warn "GitHub API error: #{response.code} - #{response.message}"
return nil
end
response
end
def valid_response?(response)
if response.parsed_response.is_a?(Hash) && response.parsed_response["items"].is_a?(Array)
true
else
Rails.logger.warn "GitHub API returned unexpected response structure: #{response.parsed_response}"
false
end
end
def transform_users(users_data)
users_data.map do |user|
{
login: user["login"],
name: user["login"],
text: build_user_text(user).to_json
}
end
end
def build_user_text(user)
{
icon: "<img class='size-6 rounded-full my-0' src='#{user["avatar_url"]}' alt='#{user["login"]}'>",
name: user["login"],
side: generate_user_tags(user),
description: generate_user_description(user)
}
end
def generate_user_tags(user)
tags = []
# User type tag
tags << if user["type"] == "Organization"
"<span class='ml-auto text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'>Org</span>"
else
"<span class='ml-auto text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'>User</span>"
end
# Site admin tag (if applicable)
if user["site_admin"]
tags << "<span class='text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'>Admin</span>"
end
tags.join(" ")
end
def generate_user_description(user)
parts = []
# Add GitHub profile URL
parts << "github.com/#{user["login"]}"
# Add user type
parts << "GitHub #{user["type"].downcase}"
parts.join(" - ")
end
end
Direct API Integration (Rick and Morty)
Load data directly from CORS-enabled external APIs without a Rails backend. The Rick and Morty API is one of the few public APIs that allows direct browser requests, making it perfect for this example.
✨ Direct API Support for CORS-enabled APIs
Some APIs like Rick and Morty allow direct browser requests. The select controller intelligently handles these APIs with minimal configuration:
- Smart auto-detection: Automatically finds data in
results
,data
, oritems
fields - Simple configuration: Often just need to set
searchParam
if it's not "query" - Automatic pagination: When using
virtualScroll
, pagination is handled seamlessly - No backend needed: Works directly from the browser for CORS-enabled APIs
Characters with images, status, species, and type information
Locations with type and dimension information
Episodes with episode codes and air dates
<div class="space-y-6">
<div class="w-full">
<label class="block text-sm font-medium mb-1">Rick and Morty Characters</label>
<%= select_tag :rick_morty_characters,
nil,
include_blank: "Search for characters...",
class: "w-full",
data: {
controller: "select",
select_url_value: "https://rickandmortyapi.com/api/character",
select_value_field_value: "id",
select_label_field_value: "name",
select_search_param_value: "name",
select_per_page_value: 20,
select_virtual_scroll_value: true,
select_image_field_value: "image",
select_subtitle_field_value: "status",
select_badge_field_value: "species",
select_meta_fields_value: "type"
} %>
<p class="text-xs text-neutral-500 mt-1">
Characters with images, status, species, and type information
</p>
</div>
<div class="w-full">
<label class="block text-sm font-medium mb-1">Rick and Morty Locations</label>
<%= select_tag :rick_morty_locations,
nil,
include_blank: "Search for locations...",
class: "w-full",
data: {
controller: "select",
select_url_value: "https://rickandmortyapi.com/api/location",
select_value_field_value: "id",
select_label_field_value: "name",
select_search_param_value: "name",
select_per_page_value: 20,
select_virtual_scroll_value: true,
select_subtitle_field_value: "type",
select_badge_field_value: "dimension"
} %>
<p class="text-xs text-neutral-500 mt-1">
Locations with type and dimension information
</p>
</div>
<div class="w-full">
<label class="block text-sm font-medium mb-1">Rick and Morty Episodes</label>
<%= select_tag :rick_morty_episodes,
nil,
include_blank: "Search for episodes...",
class: "w-full",
data: {
controller: "select",
select_url_value: "https://rickandmortyapi.com/api/episode",
select_value_field_value: "id",
select_label_field_value: "name",
select_search_param_value: "name",
select_per_page_value: 20,
select_virtual_scroll_value: true,
select_subtitle_field_value: "episode",
select_badge_field_value: "air_date"
} %>
<p class="text-xs text-neutral-500 mt-1">
Episodes with episode codes and air dates
</p>
</div>
</div>
Countries with Flags
Beautiful country selector with flag icons, country codes, and regional information. Uses the countries
gem (version 8+) for comprehensive ISO 3166 country data.
Countries with flags & codes
Countries with flags & codes
Countries with phone codes
Countries organized by geographic region
Select your preferred language
<%# Make sure to install the "countries" gem (version 8+) %>
<div class="w-full flex flex-col gap-6 items-center">
<div class="w-full max-w-sm">
<label class="block text-sm font-medium mb-1">Select Country</label>
<%= select_tag :country,
options_for_select(
ISO3166::Country.all.map do |country|
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>",
name: country.common_name,
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>#{country.alpha2}</span>"
}.to_json, country.alpha2]
end.sort_by { |option| JSON.parse(option[0])["name"] }
),
include_blank: "Choose a country...",
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Countries with flags & codes</p>
</div>
<div class="w-full max-w-sm">
<label class="block text-sm font-medium mb-1">Select Multiple Countries</label>
<%= select_tag :countries,
options_for_select(
[["Choose countries...", "", { disabled: true, selected: true }]] +
ISO3166::Country.all.map do |country|
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>",
name: country.common_name,
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>#{country.alpha2}</span>"
}.to_json, country.alpha2]
end.sort_by { |option| JSON.parse(option[0])["name"] }
),
include_blank: false,
multiple: true,
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Countries with flags & codes</p>
</div>
<div class="w-full max-w-sm">
<label class="block text-sm font-medium mb-1">Country with Phone Code</label>
<%= select_tag :country_phone,
options_for_select(
ISO3166::Country.all.select { |c| c.country_code.present? }.map do |country|
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>",
name: country.common_name,
side: "<span class='ml-auto text-xs font-medium text-neutral-600 dark:text-neutral-300'>+#{country.country_code}</span>"
}.to_json, country.alpha2]
end.sort_by { |option| JSON.parse(option[0])["name"] }
),
include_blank: "Select country for phone number...",
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Countries with phone codes</p>
</div>
<div class="w-full max-w-sm">
<label class="block text-sm font-medium mb-1">Country by Region</label>
<%= select_tag :country_by_region,
grouped_options_for_select(
ISO3166::Country.all.group_by(&:region).sort_by { |region, _| region.to_s }.map do |region, countries|
[
region || "Unknown",
countries.map do |country|
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>",
name: country.common_name,
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>#{country.alpha2}</span>"
}.to_json, country.alpha2]
end.sort_by { |option| JSON.parse(option[0])["name"] }
]
end
),
include_blank: "Select country by region...",
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Countries organized by geographic region</p>
</div>
<div class="w-full max-w-sm">
<label class="block text-sm font-medium mb-1">Select Language</label>
<%= select_tag :language,
options_for_select(
[
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/cn.svg' alt='Chinese flag'>",
name: "Chinese",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>中文</span>"
}.to_json, "zh"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/gb.svg' alt='English flag'>",
name: "English",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>English</span>"
}.to_json, "en"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/fr.svg' alt='French flag'>",
name: "French",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Français</span>"
}.to_json, "fr"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/de.svg' alt='German flag'>",
name: "German",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Deutsch</span>"
}.to_json, "de"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/it.svg' alt='Italian flag'>",
name: "Italian",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Italiano</span>"
}.to_json, "it"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/jp.svg' alt='Japanese flag'>",
name: "Japanese",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>日本語</span>"
}.to_json, "ja"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/kr.svg' alt='Korean flag'>",
name: "Korean",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>한국어</span>"
}.to_json, "ko"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/ru.svg' alt='Russian flag'>",
name: "Russian",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Русский</span>"
}.to_json, "ru"],
[{
icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/es.svg' alt='Spanish flag'>",
name: "Spanish",
side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Español</span>"
}.to_json, "es"]
]
),
include_blank: "Choose a language...",
class: "w-full",
data: {
controller: "select"
} %>
<p class="text-xs text-neutral-500 mt-1">Select your preferred language</p>
</div>
</div>
Configuration
The combobox component is powered by TomSelect and a Stimulus controller that provides extensive configuration options.
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
url
|
URL for loading options asynchronously. When provided, enables remote data loading with pagination |
String
|
null
|
valueField
|
Field to use for the option value when loading from URL |
String
|
"value"
|
labelField
|
Field to use for the option label when loading from URL |
String
|
"label"
|
allowNew
|
Allow users to create new options that don't exist in the list |
Boolean
|
false
|
disableDropdownInput
|
Disable the search input in the dropdown |
Boolean
|
false
|
disableTyping
|
Make the input read-only while still allowing keyboard navigation |
Boolean
|
false
|
submitOnChange
|
Automatically submit the form when a value is selected |
Boolean
|
false
|
updateField
|
Update another field with data from the selected option |
Boolean
|
false
|
updateFieldTarget
|
CSS selector for the field to update when an option is selected |
String
|
null
|
updateFieldSource
|
Property from the selected option to use when updating the target field |
String
|
"name"
|
virtualScroll
|
Enable virtual scrolling for large datasets (requires URL value) |
Boolean
|
false
|
perPage
|
Number of items to load per page when using async loading |
Number
|
60
|
scrollButtons
|
Show scroll buttons at the top and bottom of the dropdown for easier navigation |
Boolean
|
false
|
optgroupColumns
|
Display option groups in columns for better organization |
Boolean
|
false
|
responseDataField
|
Override where to find the array of items (auto-detects: data, results, items) |
String
|
"data"
|
searchParam
|
Search parameter name for the API request |
String
|
"query"
|
showCount
|
Show count of selected items instead of individual tags (multi-select only) |
Boolean
|
false
|
countText
|
Text to display after the count number (e.g., '3 selected') |
String
|
"selected"
|
countTextSingular
|
Text to display when only one item is selected (e.g., '1 item selected'). If not provided, uses countText for all counts |
String
|
""
|
Features
- Search & Filter: Built-in search functionality with customizable search fields
- Multi-select: Support for selecting multiple options with checkboxes and tags
- Custom Rendering: Flexible option and item templates with support for icons and descriptions
- Async Loading: Load options from remote APIs with pagination and infinite scroll
- Keyboard Navigation: Full keyboard support including type-ahead search