Dropdown Menu Rails Components
Dropdown menus with intelligent positioning, keyboard navigation, mobile support, and nested submenus. Perfect for navigation, user menus, and action lists.
Installation
1. Stimulus Controller Setup
Start by adding the following 3 stimulus controllers to your project:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["item"]; // Targets for the menu items
static values = { index: Number }; // Index of the current item
connect() {
this.indexValue = -1;
this.searchString = "";
this.#updateTabstops();
document.addEventListener("keydown", (event) => {
if (document.activeElement.tagName === "INPUT") return;
if (this.element.open && event.key.length === 1 && event.key.match(/[a-z]/i)) {
event.preventDefault();
event.stopPropagation();
this.#handleTextSearch(event.key.toLowerCase());
}
});
this.itemTargets.forEach((item) => {
item.addEventListener("click", () => {
const isNestedDropdownButton = item.hasAttribute("data-dropdown-popover-target");
if (!isNestedDropdownButton) {
const parentDropdown = this.element.closest('[data-controller="dropdown-popover"]');
const parentContextMenu = this.element.closest('[data-controller="context-menu"]');
let shouldAutoClose = true;
if (parentDropdown) {
shouldAutoClose = parentDropdown.getAttribute("data-dropdown-popover-auto-close-value") !== "false";
} else if (parentContextMenu) {
shouldAutoClose = parentContextMenu.getAttribute("data-context-menu-auto-close-value") !== "false";
}
if (shouldAutoClose) {
this.element.dispatchEvent(new Event("menu-item-clicked", { bubbles: true }));
}
}
});
item.addEventListener("mouseenter", (event) => {
if (!this.element.open) return;
event.preventDefault();
// Only handle mouse enter for visible items
const currentItem = event.currentTarget;
if (
currentItem.disabled ||
currentItem.classList.contains("disabled") ||
currentItem.classList.contains("hidden") ||
currentItem.style.display === "none"
) {
return;
}
// Check computed styles for actual visibility (handles Tailwind responsive classes)
const computedStyle = window.getComputedStyle(currentItem);
if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
return;
}
// Close any sibling nested dropdowns with a delay
const siblingDropdowns = this.element.querySelectorAll(
'[data-controller="dropdown-popover"][data-dropdown-popover-nested-value="true"]'
);
siblingDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
const isHoverEnabled = controller?.hoverValue;
const isCurrentItemNestedDropdown = event.currentTarget.hasAttribute("data-dropdown-popover-target");
// Only close if hover is enabled and current item is not a nested dropdown
if (
controller &&
controller.menuTarget.open &&
!dropdown.contains(event.currentTarget) &&
isHoverEnabled &&
!isCurrentItemNestedDropdown
) {
setTimeout(() => {
controller.close();
}, 100);
}
});
this.indexValue = this.itemTargets.indexOf(event.currentTarget);
this.#updateTabstops();
this.#focusCurrentItem();
});
item.addEventListener("mouseleave", (event) => {
if (!this.element.open) return;
const isContextMenu = this.element.closest('[data-controller="context-menu"]');
const parentDropdown = this.element.closest('[data-controller="dropdown-popover"]');
// Find all open nested dropdown controllers within this menu
const openNestedControllers = Array.from(this.element.querySelectorAll('[data-controller="dropdown-popover"]'))
.map((dropdown) => this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"))
.filter((controller) => controller?.menuTarget?.open);
if (openNestedControllers.length > 0) {
// Focus the last (deepest) open nested controller's button
const deepestController = openNestedControllers[openNestedControllers.length - 1];
const parentMenu = deepestController.buttonTarget.closest('[data-controller="menu"]');
const parentMenuController = this.application.getControllerForElementAndIdentifier(parentMenu, "menu");
// Update tabindex state in the parent menu using public method
const buttonIndex = parentMenuController.itemTargets.indexOf(deepestController.buttonTarget);
parentMenuController.updateTabstopsWithIndex(buttonIndex);
deepestController.buttonTarget.focus();
} else if (this.indexValue === this.itemTargets.indexOf(event.currentTarget)) {
if (isContextMenu) {
this.element.focus();
} else if (parentDropdown) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
dropdownController.buttonTarget.focus();
}
// Remove background classes when leaving item
event.currentTarget.classList.remove("!bg-neutral-400/20");
this.indexValue = -1;
this.#updateTabstops();
}
});
item.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
const input = item.querySelector('input[type="checkbox"], input[type="radio"]');
if (input) {
event.preventDefault();
input.checked = !input.checked;
const parentDropdown = this.element.closest('[data-controller="dropdown-popover"]');
const parentContextMenu = this.element.closest('[data-controller="context-menu"]');
let shouldAutoClose = true;
if (parentDropdown) {
shouldAutoClose = parentDropdown.getAttribute("data-dropdown-popover-auto-close-value") !== "false";
} else if (parentContextMenu) {
shouldAutoClose = parentContextMenu.getAttribute("data-context-menu-auto-close-value") !== "false";
}
if (shouldAutoClose) {
this.element.dispatchEvent(new Event("menu-item-clicked"));
}
}
} else if (event.key === "ArrowRight") {
const isNestedDropdownButton = item.hasAttribute("data-dropdown-popover-target");
if (isNestedDropdownButton) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
item.closest('[data-controller="dropdown-popover"]'),
"dropdown-popover"
);
dropdownController.show();
// Add background classes when opening nested dropdown
if (dropdownController.nestedValue) {
item.classList.add("!bg-neutral-400/20");
}
}
} else if (event.key === "ArrowLeft") {
const parentDropdown = this.element.closest('[data-dropdown-popover-nested-value="true"]');
if (parentDropdown) {
event.stopPropagation();
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
// Remove background classes when closing nested dropdown
dropdownController.buttonTarget.classList.remove("!bg-neutral-400/20");
dropdownController.close();
dropdownController.buttonTarget.focus();
}
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.element.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
});
const isNestedDropdownButton = item.hasAttribute("data-dropdown-popover-target");
if (isNestedDropdownButton) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
item.closest('[data-controller="dropdown-popover"]'),
"dropdown-popover"
);
if (dropdownController?.menuTarget?.open) {
event.preventDefault();
event.stopPropagation();
const childMenuController = this.application.getControllerForElementAndIdentifier(
dropdownController.menuTarget,
"menu"
);
// Add active path class and background classes to parent item
item.classList.add("active-menu-path");
item.classList.add("!bg-neutral-400/20");
if (event.key === "ArrowDown") {
childMenuController.selectFirst();
} else {
childMenuController.selectLast();
}
return;
}
}
}
});
});
this.element.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") {
const parentDropdown = this.element.closest('[data-dropdown-popover-nested-value="true"]');
if (parentDropdown) {
event.stopPropagation();
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
// Remove background classes when closing nested dropdown
dropdownController.buttonTarget.classList.remove("!bg-neutral-400/20");
dropdownController.close();
dropdownController.buttonTarget.focus();
}
}
});
this.element.addEventListener("mouseleave", (event) => {
if (!this.element.open) return;
// Check all nested dropdown controllers for open state
const hasOpenNested = Array.from(this.element.querySelectorAll('[data-controller="dropdown-popover"]')).some(
(dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
return controller?.menuTarget?.open;
}
);
if (!hasOpenNested) {
const parentDropdown = event.target.closest('[data-controller="dropdown-popover"]');
if (parentDropdown) {
const dropdownController = this.application.getControllerForElementAndIdentifier(
parentDropdown,
"dropdown-popover"
);
if (dropdownController?.buttonTarget) {
dropdownController.buttonTarget.focus();
}
}
}
});
}
reset() {
this.indexValue = -1;
this.searchString = "";
this.#updateTabstops();
}
// Helper method to get only visible and enabled items
#getVisibleItems() {
return this.itemTargets.filter((item) => {
// Check if item is disabled
if (item.disabled || item.classList.contains("disabled")) {
return false;
}
// Check if item is explicitly hidden
if (item.classList.contains("hidden")) {
return false;
}
// Check if item has inline style display none
if (item.style.display === "none") {
return false;
}
// Check computed styles for actual visibility (handles Tailwind responsive classes)
const computedStyle = window.getComputedStyle(item);
if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
return false;
}
return true;
});
}
prev() {
this.element.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
el.classList.remove("!bg-neutral-400/20");
});
// Get only visible and enabled items
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
const currentVisibleIndex = visibleItems.indexOf(this.itemTargets[this.indexValue]);
if (currentVisibleIndex === -1) {
this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
} else if (currentVisibleIndex > 0) {
this.indexValue = this.itemTargets.indexOf(visibleItems[currentVisibleIndex - 1]);
} else {
this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
}
this.#updateTabstops();
this.#focusCurrentItem();
}
next() {
this.element.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
el.classList.remove("!bg-neutral-400/20");
});
// Get only visible and enabled items
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
const currentVisibleIndex = visibleItems.indexOf(this.itemTargets[this.indexValue]);
if (currentVisibleIndex === -1) {
this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
} else if (currentVisibleIndex < visibleItems.length - 1) {
this.indexValue = this.itemTargets.indexOf(visibleItems[currentVisibleIndex + 1]);
} else {
this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
}
this.#updateTabstops();
this.#focusCurrentItem();
}
preventScroll(event) {
event.preventDefault();
}
#updateTabstops() {
this.itemTargets.forEach((element, index) => {
element.tabIndex = index === this.indexValue && this.indexValue !== -1 ? 0 : -1;
});
}
#focusCurrentItem() {
this.itemTargets[this.indexValue].focus();
}
get #lastIndex() {
return this.itemTargets.length - 1;
}
selectFirst() {
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
this.#updateTabstops();
this.#focusCurrentItem();
}
selectLast() {
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) return;
this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
this.#updateTabstops();
this.#focusCurrentItem();
}
#handleTextSearch(key) {
clearTimeout(this.searchTimeout);
this.searchString += key;
const searchStr = this.searchString;
const visibleItems = this.#getVisibleItems();
const matchedItem = visibleItems.find((item) => {
const textElement = item.querySelector(".menu-item-text") || item;
return textElement.textContent.trim().toLowerCase().startsWith(searchStr);
});
if (matchedItem) {
this.indexValue = this.itemTargets.indexOf(matchedItem);
this.#updateTabstops();
this.#focusCurrentItem();
}
this.searchTimeout = setTimeout(() => {
this.searchString = "";
}, 500);
}
// Add public method for external access
updateTabstopsWithIndex(newIndex) {
this.indexValue = newIndex;
this.#updateTabstops();
}
}
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
export default class extends Controller {
static targets = ["button", "menu", "template"];
static classes = ["flip"];
static values = {
autoClose: { type: Boolean, default: true }, // Whether to close the menu when clicking on an item
nested: { type: Boolean, default: false }, // Whether the menu is nested
hover: { type: Boolean, default: false }, // Whether to show the menu on hover
autoPosition: { type: Boolean, default: true }, // Whether to automatically position the menu
lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the menu content
placement: { type: String, default: "bottom-start" }, // The placement(s) of the menu - can be multiple separated by spaces
dialogMode: { type: Boolean, default: true }, // Whether to use dialog mode
turboFrameSrc: { type: String, default: "" }, // URL for Turbo Frame lazy loading
};
connect() {
// Initialize non-dialog menu if dialogMode is false
if (!this.dialogModeValue && !this.menuTarget.hasAttribute("role")) {
this.menuTarget.setAttribute("role", "menu");
this.menuTarget.setAttribute("aria-modal", "false");
this.menuTarget.setAttribute("tabindex", "-1");
}
this.menuTarget.addEventListener("menu-item-clicked", () => {
if (this.autoCloseValue) {
this.close();
let parent = this.element.closest('[data-controller="dropdown-popover"]');
while (parent) {
const parentController = this.application.getControllerForElementAndIdentifier(parent, "dropdown-popover");
if (parentController && parentController.autoCloseValue) {
parentController.close();
}
parent = parent.parentElement.closest('[data-controller="dropdown-popover"]');
}
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && this.isOpen) {
this.close();
}
});
this.buttonTarget.addEventListener("keydown", (event) => {
if (!this.isOpen) return;
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (event.key === "ArrowDown") {
event.preventDefault();
menuController.selectFirst();
} else if (event.key === "ArrowUp") {
event.preventDefault();
menuController.selectLast();
}
});
// Add scroll listener to update position
this.scrollHandler = () => {
if (this.isOpen && this.autoPositionValue) {
this.#updatePosition();
}
};
window.addEventListener("scroll", this.scrollHandler, true);
// Update position when window is resized or menu content changes
this.resizeObserver = new ResizeObserver(() => {
if (this.isOpen) {
this.#updatePosition();
}
});
// Observe both document.body and the menuTarget element
this.resizeObserver.observe(document.body);
this.resizeObserver.observe(this.menuTarget);
// Add MutationObserver to detect content changes inside the menu
this.mutationObserver = new MutationObserver(() => {
if (this.isOpen) {
// Slight delay to allow DOM changes to complete
setTimeout(() => this.#updatePosition(), 10);
}
});
this.mutationObserver.observe(this.menuTarget, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
// Add hover functionality for hover-enabled dropdowns
if (this.hoverValue) {
let hoverTimeout;
this.buttonTarget.addEventListener("mouseenter", () => {
clearTimeout(hoverTimeout);
this.show();
});
this.element.addEventListener("mouseleave", (event) => {
const toElement = event.relatedTarget;
const isMovingToNestedMenu =
toElement &&
(toElement.closest('[data-dropdown-popover-target="menu"]') ||
toElement.closest('[data-dropdown-popover-target="button"]') ||
// Check if moving to a child menu
toElement.closest('[data-controller="dropdown-popover"][data-dropdown-popover-nested-value="true"]'));
if (!isMovingToNestedMenu) {
hoverTimeout = setTimeout(() => {
// Check if any child menu is being hovered before closing
const hasHoveredChild = Array.from(
this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]')
).some(
(dropdown) =>
dropdown.matches(":hover") ||
dropdown.querySelector("[data-dropdown-popover-target='menu']").matches(":hover")
);
if (!hasHoveredChild) {
this.close();
// Reset focus state when menu is closed with hover
if (this.nestedValue) {
this.buttonTarget.classList.remove("active-menu-path");
this.buttonTarget.classList.remove("!bg-neutral-400/20");
// Reset the tabindex in the parent menu if this is a nested dropdown
const parentMenu = this.element.closest('[data-controller="menu"]');
if (parentMenu) {
const menuController = this.application.getControllerForElementAndIdentifier(parentMenu, "menu");
if (menuController) {
menuController.reset();
}
}
}
}
}, 200);
}
});
this.menuTarget.addEventListener("mouseenter", () => {
clearTimeout(hoverTimeout);
});
}
}
disconnect() {
this.resizeObserver.disconnect();
// Disconnect mutation observer
this.mutationObserver.disconnect();
// Remove scroll listener
window.removeEventListener("scroll", this.scrollHandler, true);
}
get isOpen() {
if (this.dialogModeValue) {
return this.menuTarget.open;
} else {
return this.menuTarget.classList.contains("hidden") === false;
}
}
async show() {
// Close all other open dropdowns that aren't in the same hierarchy
const allDropdowns = this.application.controllers.filter(
(c) => c.identifier === "dropdown-popover" && c !== this && c.isOpen
);
allDropdowns.forEach((controller) => {
if (!this.element.contains(controller.element) && !controller.element.contains(this.element)) {
controller.close();
}
});
// Close any sibling nested dropdowns first
if (this.nestedValue) {
const parentMenu = this.element.closest('[data-controller="menu"]');
if (parentMenu) {
const siblingDropdowns = parentMenu.querySelectorAll(
'[data-controller="dropdown-popover"][data-dropdown-popover-nested-value="true"]'
);
siblingDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller !== this && controller.isOpen) {
controller.close();
}
});
}
}
// If we have lazy loading enabled, load the content now
if (this.lazyLoadValue && !this.contentLoaded) {
await this.#loadTemplateContent();
this.contentLoaded = true;
}
if (this.dialogModeValue) {
this.menuTarget.show();
} else {
this.menuTarget.classList.remove("hidden");
}
this.#updateExpanded();
this.#updatePosition();
// Add active-menu-path class and background classes to the button when showing the dropdown
if (this.nestedValue) {
this.buttonTarget.classList.add("active-menu-path");
this.buttonTarget.classList.add("!bg-neutral-400/20");
}
requestAnimationFrame(() => {
// Add appropriate classes based on placement
if (this.placementValue.startsWith("top")) {
this.menuTarget.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100", "scale-100", "opacity-100");
} else {
this.menuTarget.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100", "scale-100", "opacity-100");
}
// Check for autofocus elements inside the menu
const autofocusElement = this.menuTarget.querySelector('[autofocus="true"], [autofocus]');
if (autofocusElement) {
// Focus the autofocus element
setTimeout(() => {
autofocusElement.focus();
// If it's an input or textarea, position cursor at the end
if (autofocusElement.tagName === "INPUT" || autofocusElement.tagName === "TEXTAREA") {
const length = autofocusElement.value.length;
autofocusElement.setSelectionRange(length, length);
}
}, 0);
} else if (this.nestedValue) {
this.menuTarget.focus();
} else {
this.buttonTarget.focus();
}
});
}
close() {
// Reset focus states in the menu before closing
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
menuController.reset();
}
// Close any child dropdowns first
const childDropdowns = this.menuTarget.querySelectorAll('[data-controller="dropdown-popover"]');
childDropdowns.forEach((dropdown) => {
const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
if (controller && controller.isOpen) {
controller.close();
}
});
// Remove all active-menu-path classes within this menu
this.menuTarget.querySelectorAll(".active-menu-path").forEach((el) => {
el.classList.remove("active-menu-path");
});
// Also remove from the button that triggered this dropdown and remove background classes
this.buttonTarget.classList.remove("active-menu-path");
this.buttonTarget.classList.remove("!bg-neutral-400/20");
// If this is a hover-enabled dropdown, ensure we blur any focused elements
if (this.hoverValue) {
const focusedElement = this.element.querySelector(":focus");
if (focusedElement) {
focusedElement.blur();
}
}
this.menuTarget.classList.remove("scale-100", "opacity-100", "[&[open]]:scale-100", "[&[open]]:opacity-100");
setTimeout(() => {
if (this.dialogModeValue) {
this.menuTarget.close();
} else {
this.menuTarget.classList.add("hidden");
}
this.#updateExpanded();
}, 100);
}
toggle() {
this.isOpen ? this.close() : this.show();
}
closeOnClickOutside({ target }) {
const isClickInNestedDropdown = target.closest('[data-dropdown-popover-nested-value="true"]');
if (isClickInNestedDropdown) return;
if (!this.element.contains(target)) {
this.close();
}
}
// Prevent close method to stop click propagation
preventClose(event) {
// Stop propagation to prevent the closeOnClickOutside handler from being triggered
event.stopPropagation();
}
// Reset method to handle dialog close events
reset() {
// This method is called when the dialog is closed
// It's responsible for any cleanup needed after closing
// If we have a menu controller, reset its state
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController && typeof menuController.reset === "function") {
menuController.reset();
}
// Dispatch a custom event that can be listened for by other controllers
const resetEvent = new CustomEvent("dropdown-reset", {
bubbles: true,
detail: { controller: this },
});
this.element.dispatchEvent(resetEvent);
}
#updateExpanded() {
this.buttonTarget.ariaExpanded = this.isOpen;
}
async #loadTemplateContent() {
// Find the container in the menu to append content to
const container = this.menuTarget.querySelector("[data-dropdown-popover-content]") || this.menuTarget;
// Check if we should use Turbo Frame lazy loading
if (this.turboFrameSrcValue) {
// Look for a turbo-frame in the container
let turboFrame = container.querySelector("turbo-frame");
if (!turboFrame) {
// Create a turbo-frame if it doesn't exist
turboFrame = document.createElement("turbo-frame");
turboFrame.id = "dropdown-lazy-content";
// Clear any loading indicators or placeholder content
container.innerHTML = "";
container.appendChild(turboFrame);
}
// Set the src to trigger the lazy load
turboFrame.src = this.turboFrameSrcValue;
// Wait for the turbo-frame to load
return new Promise((resolve) => {
const handleLoad = () => {
turboFrame.removeEventListener("turbo:frame-load", handleLoad);
this.#refreshMenuController();
resolve();
};
turboFrame.addEventListener("turbo:frame-load", handleLoad);
// Fallback timeout in case the frame doesn't load
setTimeout(() => {
turboFrame.removeEventListener("turbo:frame-load", handleLoad);
this.#refreshMenuController();
resolve();
}, 5000);
});
} else if (this.hasTemplateTarget) {
// Use template-based lazy loading (existing behavior)
const templateContent = this.templateTarget.content.cloneNode(true);
// Clear any loading indicators or placeholder content
container.innerHTML = "";
// Append the template content
container.appendChild(templateContent);
this.#refreshMenuController();
}
}
#refreshMenuController() {
// Refresh the menu controller to pick up new targets from the loaded content
setTimeout(() => {
const menuController = this.application.getControllerForElementAndIdentifier(this.menuTarget, "menu");
if (menuController) {
// Disconnect and reconnect the menu controller to refresh its targets
menuController.disconnect();
menuController.connect();
}
}, 10);
}
async #updatePosition() {
// Parse placement value to support multiple placements
const placements = this.placementValue.split(/[\s,]+/).filter(Boolean);
const primaryPlacement = placements[0] || "bottom-start";
const fallbackPlacements = placements.slice(1);
const middleware = [
offset(this.nestedValue ? -4 : 4), // 0 offset for nested, 4px for regular
flip({
fallbackPlacements: fallbackPlacements.length > 0 ? fallbackPlacements : ["top-start", "bottom-start"],
}),
shift({ padding: 8 }), // Keep 8px padding from window edges
];
if (this.nestedValue) {
// For nested dropdowns, position to the right of the button
const { x, y } = await computePosition(this.buttonTarget, this.menuTarget, {
placement: "right-start",
middleware,
});
Object.assign(this.menuTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
} else {
// Use the primary placement from the controller
const { x, y } = await computePosition(this.buttonTarget, this.menuTarget, {
placement: primaryPlacement,
middleware: this.autoPositionValue ? middleware : [offset(this.nestedValue ? -4 : 4)],
});
Object.assign(this.menuTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
}
}
}
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input", "item", "results", "noResults"];
static values = {
placeholder: { type: String, default: "Search..." },
noResultsText: { type: String, default: "No results found" },
};
connect() {
this.originalItems = [...this.itemTargets];
this.selectedIndex = -1;
this.keyboardNavigation = false; // Track if user is navigating with keyboard
this.setupAccessibility();
this.bindEvents();
this.bindHoverEvents();
}
disconnect() {
this.unbindEvents();
this.unbindHoverEvents();
}
setupAccessibility() {
// Set up ARIA attributes for better accessibility
if (this.hasInputTarget) {
this.inputTarget.setAttribute("role", "combobox");
this.inputTarget.setAttribute("aria-expanded", "false");
this.inputTarget.setAttribute("aria-autocomplete", "list");
this.inputTarget.setAttribute("aria-haspopup", "listbox");
// Generate unique IDs if not present
if (!this.inputTarget.id) {
this.inputTarget.id = `searchable-dropdown-input-${Math.random().toString(36).substring(7)}`;
}
}
if (this.hasResultsTarget) {
this.resultsTarget.setAttribute("role", "listbox");
if (this.hasInputTarget) {
this.resultsTarget.setAttribute("aria-labelledby", this.inputTarget.id);
}
}
// Set up items with proper roles and IDs
this.itemTargets.forEach((item, index) => {
item.setAttribute("role", "option");
item.setAttribute("aria-selected", "false");
if (!item.id) {
item.id = `searchable-dropdown-option-${index}-${Math.random().toString(36).substring(7)}`;
}
});
}
bindEvents() {
// Bind regular event handlers (arrow functions don't need binding)
this.handleKeydown = this.handleKeydown.bind(this);
this.handleInputFocus = this.handleInputFocus.bind(this);
if (this.hasInputTarget) {
this.inputTarget.addEventListener("keydown", this.handleKeydown);
this.inputTarget.addEventListener("focus", this.handleInputFocus);
}
}
bindHoverEvents() {
this.itemTargets.forEach((item) => {
// Use capture phase to intercept events before menu controller
item.addEventListener("mouseenter", this.handleItemMouseEnter, true);
item.addEventListener("mouseleave", this.handleItemMouseLeave, true);
// Reset keyboard navigation when mouse moves
item.addEventListener("mousemove", this.handleMouseMove, true);
});
// Add mouse leave to the results container to clear selection when leaving all items
if (this.hasResultsTarget) {
this.resultsTarget.addEventListener("mouseleave", this.handleResultsMouseLeave, true);
}
}
unbindEvents() {
if (this.hasInputTarget) {
this.inputTarget.removeEventListener("keydown", this.handleKeydown);
this.inputTarget.removeEventListener("focus", this.handleInputFocus);
}
}
unbindHoverEvents() {
this.itemTargets.forEach((item) => {
item.removeEventListener("mouseenter", this.handleItemMouseEnter, true);
item.removeEventListener("mouseleave", this.handleItemMouseLeave, true);
item.removeEventListener("mousemove", this.handleMouseMove, true);
});
// Remove results container event listener
if (this.hasResultsTarget) {
this.resultsTarget.removeEventListener("mouseleave", this.handleResultsMouseLeave, true);
}
}
handleInputFocus() {
if (this.hasInputTarget) {
this.inputTarget.setAttribute("aria-expanded", "true");
}
}
handleItemMouseEnter = (event) => {
// Prevent the menu controller from handling this event
event.preventDefault();
event.stopPropagation();
// Don't interfere with keyboard navigation
if (this.keyboardNavigation) {
return;
}
const visibleItems = this.getVisibleItems();
const hoveredItem = event.currentTarget;
// Only update selection if the item is visible
if (!hoveredItem.classList.contains("hidden")) {
this.selectedIndex = visibleItems.indexOf(hoveredItem);
this.updateSelection(visibleItems);
}
};
handleItemMouseLeave = (event) => {
// Prevent the menu controller from handling this event
event.preventDefault();
event.stopPropagation();
// Keep the input focused when leaving an item
this.ensureInputFocus();
};
handleResultsMouseLeave = (event) => {
// Clear selection when mouse leaves the entire results area
this.clearSelection();
this.ensureInputFocus();
};
handleMouseMove = (event) => {
// Reset keyboard navigation flag when mouse moves
this.keyboardNavigation = false;
};
handleKeydown(event) {
const visibleItems = this.getVisibleItems();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
this.navigateDown(visibleItems);
break;
case "ArrowUp":
event.preventDefault();
this.navigateUp(visibleItems);
break;
case "Enter":
event.preventDefault();
this.selectCurrentItem(visibleItems);
break;
case "Escape":
event.preventDefault();
this.closeDropdown();
break;
case "Tab":
this.clearSelection();
break;
case "Home":
event.preventDefault();
this.navigateToFirst(visibleItems);
break;
case "End":
event.preventDefault();
this.navigateToLast(visibleItems);
break;
}
}
search(event) {
// Reset keyboard navigation when user starts typing
this.keyboardNavigation = false;
const query = event.target.value.toLowerCase().trim();
let hasVisibleItems = false;
this.originalItems.forEach((item) => {
const searchText = item.dataset.searchableText || item.textContent.toLowerCase();
const matches = searchText.includes(query);
if (matches) {
item.classList.remove("hidden");
hasVisibleItems = true;
} else {
item.classList.add("hidden");
}
});
// Reset selection when searching
this.clearSelection();
// Show/hide no results message
this.toggleNoResults(!hasVisibleItems);
// Update ARIA live region for screen readers
this.announceResults(hasVisibleItems, query);
// Ensure input stays focused during search
this.ensureInputFocus();
}
getVisibleItems() {
return this.itemTargets.filter((item) => !item.classList.contains("hidden"));
}
navigateDown(visibleItems) {
if (visibleItems.length === 0) return;
this.keyboardNavigation = true;
this.selectedIndex = Math.min(this.selectedIndex + 1, visibleItems.length - 1);
this.updateSelection(visibleItems);
}
navigateUp(visibleItems) {
if (visibleItems.length === 0) return;
this.keyboardNavigation = true;
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
this.updateSelection(visibleItems);
}
navigateToFirst(visibleItems) {
if (visibleItems.length === 0) return;
this.keyboardNavigation = true;
this.selectedIndex = 0;
this.updateSelection(visibleItems);
}
navigateToLast(visibleItems) {
if (visibleItems.length === 0) return;
this.keyboardNavigation = true;
this.selectedIndex = visibleItems.length - 1;
this.updateSelection(visibleItems);
}
updateSelection(visibleItems) {
// Clear all selections
this.itemTargets.forEach((item) => {
item.setAttribute("aria-selected", "false");
item.classList.remove("bg-neutral-100", "dark:bg-neutral-700/50");
});
// Update input aria-activedescendant
if (this.hasInputTarget) {
if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
const selectedItem = visibleItems[this.selectedIndex];
this.inputTarget.setAttribute("aria-activedescendant", selectedItem.id);
selectedItem.setAttribute("aria-selected", "true");
selectedItem.classList.add("bg-neutral-100", "dark:bg-neutral-700/50");
// Scroll item into view if needed
this.scrollItemIntoView(selectedItem);
// Keep focus on input for continuous typing
this.inputTarget.focus();
} else {
this.inputTarget.removeAttribute("aria-activedescendant");
// Keep focus on input
this.inputTarget.focus();
}
}
}
scrollItemIntoView(item) {
if (this.hasResultsTarget) {
const container = this.resultsTarget;
const containerRect = container.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
if (itemRect.bottom > containerRect.bottom || itemRect.top < containerRect.top) {
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
}
selectCurrentItem(visibleItems) {
if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
const selectedItem = visibleItems[this.selectedIndex];
this.selectItem(selectedItem);
}
}
selectItem(item) {
// Dispatch custom event with selected item data
const selectEvent = new CustomEvent("searchable-dropdown:select", {
detail: {
item: item,
value: item.dataset.value || item.textContent.trim(),
text: item.textContent.trim(),
},
bubbles: true,
});
this.element.dispatchEvent(selectEvent);
// Optional: Update input with selected value
if (this.hasInputTarget) {
// You might want to clear the input or set it to the selected value
// this.inputTarget.value = item.textContent.trim()
}
// Close dropdown (this will be handled by the dropdown-popover controller)
this.closeDropdown();
}
closeDropdown() {
// Update aria-expanded before closing
if (this.hasInputTarget) {
this.inputTarget.setAttribute("aria-expanded", "false");
}
// Get the dropdown-popover controller and call its close method
const dropdownController = this.application.getControllerForElementAndIdentifier(this.element, "dropdown-popover");
if (dropdownController) {
dropdownController.close();
}
this.clearSelection();
}
clearSelection() {
this.selectedIndex = -1;
this.keyboardNavigation = false;
this.itemTargets.forEach((item) => {
item.setAttribute("aria-selected", "false");
item.classList.remove("bg-neutral-100", "dark:bg-neutral-700/50");
});
if (this.hasInputTarget) {
this.inputTarget.removeAttribute("aria-activedescendant");
}
}
toggleNoResults(show) {
if (this.hasNoResultsTarget) {
if (show) {
this.noResultsTarget.classList.remove("hidden");
} else {
this.noResultsTarget.classList.add("hidden");
}
}
}
announceResults(hasResults, query) {
// Create or update ARIA live region for screen reader announcements
let liveRegion = this.element.querySelector("[aria-live]");
if (!liveRegion) {
liveRegion = document.createElement("div");
liveRegion.setAttribute("aria-live", "polite");
liveRegion.setAttribute("aria-atomic", "true");
liveRegion.className = "sr-only";
this.element.appendChild(liveRegion);
}
if (query.length > 0) {
const visibleCount = this.getVisibleItems().length;
if (hasResults) {
liveRegion.textContent = `${visibleCount} ${visibleCount === 1 ? "result" : "results"} available`;
} else {
liveRegion.textContent = "No results found";
}
} else {
liveRegion.textContent = "";
}
}
// Action method for clicking on items
itemClick(event) {
event.preventDefault();
event.stopPropagation();
this.selectItem(event.currentTarget);
}
// Reset search when dropdown opens
reset() {
this.keyboardNavigation = false;
if (this.hasInputTarget) {
this.inputTarget.value = "";
}
// Show all items
this.originalItems.forEach((item) => {
item.classList.remove("hidden");
});
// Hide no results
this.toggleNoResults(false);
// Clear selection
this.clearSelection();
// Focus input and ensure it stays focused
this.ensureInputFocus();
}
// Ensure input maintains focus for continuous typing
ensureInputFocus() {
if (this.hasInputTarget) {
// Use setTimeout to ensure focus happens after any other focus changes
setTimeout(() => {
this.inputTarget.focus();
}, 0);
}
}
// Handle when new items are added (Stimulus target callbacks)
itemTargetConnected(element) {
// Bind hover events to new items with capture phase
element.addEventListener("mouseenter", this.handleItemMouseEnter, true);
element.addEventListener("mouseleave", this.handleItemMouseLeave, true);
element.addEventListener("mousemove", this.handleMouseMove, true);
}
itemTargetDisconnected(element) {
// Clean up hover events from removed items
element.removeEventListener("mouseenter", this.handleItemMouseEnter, true);
element.removeEventListener("mouseleave", this.handleItemMouseLeave, true);
element.removeEventListener("mousemove", this.handleMouseMove, true);
}
}
2. Floating UI Installation
The context menu component relies on Floating UI for intelligent tooltip positioning. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]/+esm"
npm install @floating-ui/dom
yarn add @floating-ui/dom
Examples
Basic dropdown
A simple dropdown menu with navigation items and actions.
<!-- Basic Menu Dropdown -->
<div class="relative w-fit" data-controller="dropdown-popover" data-dropdown-popover-flip-class="translate-y-full" data-dropdown-popover-placement-value="bottom top">
<button class="outline-none flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 p-2.5 text-xs font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle">
<svg viewBox="0 0 16 16" class="size-3.5 sm:size-4" fill="currentColor" class="size-4"><path d="M8 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM8 6.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM9.5 12.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z"></path></svg>
</button>
<dialog
class="absolute z-10 w-max rounded-lg border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
style="margin-right: initial;"
data-controller="menu"
data-dropdown-popover-target="menu"
autofocus="false"
data-action="click@document->dropdown-popover#closeOnClickOutside
close->menu#reset
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><circle cx="9" cy="4.5" r="3.5"></circle><path d="M9,9c-2.764,0-5.274,1.636-6.395,4.167-.257,.58-.254,1.245,.008,1.825,.268,.591,.777,1.043,1.399,1.239,1.618,.51,3.296,.769,4.987,.769s3.369-.259,4.987-.769c.622-.196,1.132-.648,1.399-1.239,.262-.58,.265-1.245,.008-1.825-1.121-2.531-3.631-4.167-6.395-4.167Z" fill="currentColor"></path></g></svg></span>
Profile
</a>
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M16.25,4.5h-2.357c-.335-1.29-1.5-2.25-2.893-2.25s-2.558,.96-2.893,2.25H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h6.357c.335,1.29,1.5,2.25,2.893,2.25s2.558-.96,2.893-2.25h2.357c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Zm-5.25,2.25c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5,.673,1.5,1.5-.673,1.5-1.5,1.5Z"></path><path d="M16.25,12h-6.357c-.335-1.29-1.5-2.25-2.893-2.25s-2.558,.96-2.893,2.25H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h2.357c.335,1.29,1.5,2.25,2.893,2.25s2.558-.96,2.893-2.25h6.357c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z" fill="currentColor"></path></g></svg></span>
Settings
</a>
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M17,5.75c0-1.517-1.233-2.75-2.75-2.75H3.75c-1.517,0-2.75,1.233-2.75,2.75v.75H17v-.75Z"></path><path d="M1,12.25c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75v-4.25H1v4.25Zm11.75-1.75h1c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75h-1c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75Zm-8.5,0h3c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75h-3c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75Z" fill="currentColor"></path></g></svg></span>
Billing
</a>
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M6.188,7.951l-1.404-.525c.457-1.223,1.42-2.186,2.643-2.643l.525,1.405c-.815,.305-1.458,.947-1.764,1.763Z"></path><path d="M11.812,7.951c-.306-.815-.948-1.458-1.764-1.763l.525-1.405c1.223,.457,2.186,1.42,2.643,2.643l-1.404,.525Z"></path><path d="M10.574,13.217l-.525-1.405c.815-.305,1.458-.947,1.764-1.763l1.404,.525c-.457,1.223-1.42,2.186-2.643,2.643Z"></path><path d="M7.426,13.217c-1.223-.457-2.186-1.42-2.643-2.643l1.404-.525c.306,.815,.948,1.458,1.764,1.763l-.525,1.405Z"></path><path d="M6.202,16.497c-2.174-.812-3.887-2.524-4.698-4.699l1.404-.524c.66,1.767,2.052,3.159,3.819,3.818l-.525,1.405Z"></path><path d="M11.798,16.497l-.525-1.405c1.768-.66,3.159-2.051,3.819-3.818l1.404,.524c-.812,2.175-2.524,3.888-4.698,4.699Z"></path><path d="M15.091,6.727c-.658-1.767-2.05-3.159-3.818-3.819l.525-1.405c2.175,.812,3.888,2.525,4.699,4.7l-1.406,.524Z"></path><path d="M2.908,6.727l-1.404-.524c.812-2.175,2.524-3.888,4.698-4.699l.525,1.405c-1.768,.66-3.159,2.051-3.819,3.818Z"></path><path d="M10.312,6.237c-.087,0-.176-.015-.263-.047-.675-.251-1.429-.251-2.098,0-.188,.069-.394,.062-.574-.021-.181-.083-.321-.233-.392-.42l-1.399-3.749c-.069-.186-.062-.393,.021-.574,.083-.181,.234-.322,.42-.391,1.902-.71,4.045-.71,5.947,0,.388,.145,.585,.577,.44,.965l-1.399,3.75c-.113,.302-.399,.488-.703,.488Z" fill="currentColor"></path><path d="M16.263,12.46c-.087,0-.176-.015-.263-.047l-3.749-1.399c-.388-.145-.585-.577-.44-.965,.126-.336,.189-.689,.189-1.049,0-.362-.063-.714-.188-1.047-.07-.187-.062-.393,.02-.575,.083-.181,.233-.322,.42-.392l3.749-1.399c.393-.145,.82,.053,.965,.44,.355,.949,.535,1.95,.535,2.973s-.18,2.024-.535,2.973c-.112,.301-.398,.487-.702,.487Z" fill="currentColor"></path><path d="M9,17.5c-1.022,0-2.022-.18-2.974-.535-.388-.145-.585-.577-.44-.965l1.399-3.75c.146-.388,.576-.585,.966-.44,.675,.251,1.429,.251,2.098,0,.187-.07,.393-.063,.574,.021,.181,.083,.321,.233,.392,.42l1.399,3.749c.069,.186,.062,.393-.021,.574-.083,.181-.234,.322-.42,.391-.951,.355-1.951,.535-2.974,.535Z" fill="currentColor"></path><path d="M1.737,12.46c-.304,0-.59-.186-.702-.487-.355-.949-.535-1.95-.535-2.973s.18-2.024,.535-2.973c.145-.387,.574-.584,.965-.44l3.749,1.399c.388,.145,.585,.577,.44,.965-.126,.336-.189,.689-.189,1.049,0,.362,.063,.714,.188,1.047,.07,.187,.062,.393-.02,.575-.083,.181-.233,.322-.42,.392l-3.749,1.399c-.087,.032-.176,.047-.263,.047Z" fill="currentColor"></path></g></svg></span>
Support & docs
</a>
<div class="my-1 border-t border-neutral-100 dark:border-neutral-700"></div>
<a href="javascript:void(0)" data-menu-target="item" role="menuitem" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.75,11.5c-.414,0-.75,.336-.75,.75v2.5c0,.138-.112,.25-.25,.25H5.448l1.725-1.069c.518-.322,.827-.878,.827-1.487V5.557c0-.609-.31-1.166-.827-1.487l-1.725-1.069h5.302c.138,0,.25,.112,.25,.25v2.5c0,.414,.336,.75,.75,.75s.75-.336,.75-.75V3.25c0-.965-.785-1.75-1.75-1.75H4.25c-.965,0-1.75,.785-1.75,1.75V14.75c0,.965,.785,1.75,1.75,1.75h6.5c.965,0,1.75-.785,1.75-1.75v-2.5c0-.414-.336-.75-.75-.75Z" fill="currentColor"></path><path d="M17.78,8.47l-2.75-2.75c-.293-.293-.768-.293-1.061,0s-.293,.768,0,1.061l1.47,1.47h-4.189c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h4.189l-1.47,1.47c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l2.75-2.75c.293-.293,.293-.768,0-1.061Z"></path></g></svg></span>
Logout
</a>
</div>
</dialog>
</div>
Searchable dropdown
A dropdown with search functionality to filter through options. Requires the searchable-dropdown
controller.
<!-- Searchable dropdown example -->
<div class="relative w-fit" data-controller="dropdown-popover searchable-dropdown" data-dropdown-popover-auto-close-value="true" data-dropdown-popover-placement-value="bottom top">
<button class="outline-none flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle searchable-dropdown#reset">
Select Project
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18" class="ml-2"><title>chevron-down</title><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="14.25 6.75 9 12 3.75 6.75"></polyline></g></svg>
</button>
<dialog
class="outline-none absolute overflow-hidden z-10 w-64 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside"
>
<div class="px-2 pt-2 pb-1">
<div class="relative">
<input
type="text"
class="w-full px-3 py-2 text-sm border border-neutral-200 rounded-md dark:border-neutral-700 dark:bg-neutral-700/50 focus:outline-none focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 focus:dark:ring-neutral-500 focus:dark:border-neutral-500"
placeholder="Search projects..."
autofocus
data-searchable-dropdown-target="input"
data-action="input->searchable-dropdown#search"
autocomplete="off"
spellcheck="false"
>
<svg xmlns="http://www.w3.org/2000/svg" class="absolute size-4.5 right-2 top-1/2 -translate-y-1/2 text-neutral-400" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M15.25,16c-.192,0-.384-.073-.53-.22l-3.965-3.965c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l3.965,3.965c.293,.293,.293,.768,0,1.061-.146,.146-.338,.22-.53,.22Z"></path><path d="M7.75,13.5c-3.17,0-5.75-2.58-5.75-5.75S4.58,2,7.75,2s5.75,2.58,5.75,5.75-2.58,5.75-5.75,5.75Zm0-10c-2.343,0-4.25,1.907-4.25,4.25s1.907,4.25,4.25,4.25,4.25-1.907,4.25-4.25-1.907-4.25-4.25-4.25Z" fill="currentColor"></path></g></svg>
</div>
</div>
<div class="max-h-64 overflow-y-auto small-scrollbar outline-none" data-searchable-dropdown-target="results">
<div class="flex flex-col p-2" role="menu">
<% %w[Rails\ Blocks E-commerce\ Platform Social\ Network\ App Task\ Management\ System Learning\ Management\ System Customer\ Portal Analytics\ Dashboard Content\ Management\ System Inventory\ Tracker Mobile\ App\ Backend].each do |project| %>
<button
class="group relative flex items-center rounded-md pl-2 pr-8 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50"
data-menu-target="item"
data-searchable-dropdown-target="item"
data-searchable-text="<%= project.downcase %>"
data-searchable-dropdown-item="true"
data-action="click->searchable-dropdown#itemClick"
role="menuitem"
tabindex="-1"
>
<span><%= project %></span>
<svg xmlns="http://www.w3.org/2000/svg" class="absolute right-2 top-1/2 -translate-y-1/2 w-0 group-hover:w-3 transition-all duration-200" width="12" height="12" viewBox="0 0 12 12"><g fill="currentColor"><path d="m1.75,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.543,1.396c.293-.293.768-.293,1.061,0s.293.768,0,1.061L2.28,10.78c-.146.146-.338.22-.53.22Z" stroke-width="0"></path><path d="m10.25,7.25c-.414,0-.75-.336-.75-.75V2.5h-4c-.414,0-.75-.336-.75-.75s.336-.75.75-.75h4.75c.414,0,.75.336.75.75v4.75c0,.414-.336.75-.75.75Z" stroke-width="0"></path></g></svg>
</button>
<% end %>
</div>
</div>
<div class="hidden p-4 text-sm text-neutral-500 dark:text-neutral-400 text-center" data-searchable-dropdown-target="noResults">
No results found
</div>
</dialog>
</div>
Radio dropdown
A dropdown with radio button options for single selection.
<!-- Radio dropdown example -->
<div class="relative w-fit" data-controller="dropdown-popover" data-dropdown-popover-flip-class="translate-y-full">
<button class="outline-none flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" aria-haspopup="listbox" aria-controls="radio_menu">
Radio Options
</button>
<dialog
class="outline-none absolute z-10 w-full rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
data-controller="menu"
data-dropdown-popover-target="menu"
autofocus="false"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="listbox">
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="radio" name="options" class="text-neutral-600 focus:ring-neutral-500">
<span class="text-sm font-normal">Option 1</span>
</label>
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="radio" name="options" class="text-neutral-600 focus:ring-neutral-500">
<span class="text-sm font-normal">Option 2</span>
</label>
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="radio" name="options" class="text-neutral-600 focus:ring-neutral-500">
<span class="text-sm font-normal">Option 3</span>
</label>
</div>
</dialog>
</div>
Checkbox dropdown
A dropdown with checkbox options for multiple selections. Uses data-dropdown-popover-auto-close-value="false"
to prevent closing on selection.
<!-- Checkbox dropdown example -->
<div class="relative w-fit" data-controller="dropdown-popover" data-dropdown-popover-flip-class="translate-y-full" data-dropdown-popover-auto-close-value="false">
<button class="outline-none flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" aria-haspopup="listbox" aria-controls="checkbox_menu">
Checkbox Options
</button>
<dialog
class="outline-none absolute z-10 w-full rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
data-controller="menu"
data-dropdown-popover-target="menu"
autofocus="false"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="listbox">
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox">
<span class="text-sm font-normal">Option 1</span>
</label>
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox">
<span class="text-sm font-normal">Option 2</span>
</label>
<label class="cursor-pointer flex items-center gap-2 px-2 py-2 rounded-md text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox">
<span class="text-sm font-normal">Option 3</span>
</label>
</div>
</dialog>
</div>
Nested dropdown with submenus
A dropdown with nested submenus that open on click. Perfect for complex navigation structures.
<div class="flex gap-1 border-b border-neutral-200 dark:border-neutral-700">
<!-- File Menu -->
<div class="relative" data-controller="dropdown-popover" data-dropdown-popover-flip-class="translate-y-full">
<button class="mb-1 rounded-md px-2.5 py-1.5 text-sm outline-none hover:bg-neutral-100 dark:hover:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle">Menu</button>
<dialog
class="absolute z-10 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
New File
<span class="ml-auto text-xs text-neutral-500">⌘N</span>
</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Open...
<span class="ml-auto text-xs text-neutral-500">⌘O</span>
</button>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-auto-close-value="false" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
Options
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1.5" role="listbox">
<label class="cursor-pointer flex items-center gap-2 rounded-md px-2 py-2 font-normal text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox" class="rounded text-neutral-600 focus:ring-neutral-500" />
<span class="text-sm font-normal">Option 1</span>
</label>
<label class="cursor-pointer flex items-center gap-2 rounded-md px-2 py-2 font-normal text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox" class="rounded text-neutral-600 focus:ring-neutral-500" />
<span class="text-sm font-normal">Option 2</span>
</label>
<label class="cursor-pointer flex items-center gap-2 rounded-md px-2 py-2 font-normal text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox" class="rounded text-neutral-600 focus:ring-neutral-500" />
<span class="text-sm font-normal">Option 3</span>
</label>
</div>
</dialog>
</div>
</div>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
Share
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Email link</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Messages</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Notes</button>
</div>
</dialog>
</div>
</div>
<div class="my-1 border-t border-neutral-100 dark:border-neutral-700"></div>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Save
<span class="ml-auto text-xs text-neutral-500">⌘S</span>
</button>
</div>
</dialog>
</div>
<!-- Hover Menu -->
<div class="relative hidden md:block" data-controller="dropdown-popover" data-dropdown-popover-flip-class="translate-y-full" data-dropdown-popover-hover-value="true">
<button class="mb-1 rounded-md px-2.5 py-1.5 text-sm outline-none hover:bg-neutral-100 dark:hover:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle">Hover</button>
<dialog
class="absolute z-10 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
New File
<span class="ml-auto text-xs text-neutral-500">⌘N</span>
</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Open...
<span class="ml-auto text-xs text-neutral-500">⌘O</span>
</button>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-hover-value="true" data-dropdown-popover-auto-close-value="false" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
Options
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1.5" role="listbox">
<label class="cursor-pointer flex items-center gap-2 rounded-md px-2 py-2 font-normal text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox" class="rounded text-neutral-600 focus:ring-neutral-500" />
<span class="text-sm font-normal">Option 1</span>
</label>
<label class="cursor-pointer flex items-center gap-2 rounded-md px-2 py-2 font-normal text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox" class="rounded text-neutral-600 focus:ring-neutral-500" />
<span class="text-sm font-normal">Option 2</span>
</label>
<label class="cursor-pointer flex items-center gap-2 rounded-md px-2 py-2 font-normal text-neutral-700 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" tabindex="-1">
<input type="checkbox" class="rounded text-neutral-600 focus:ring-neutral-500" />
<span class="text-sm font-normal">Option 3</span>
</label>
</div>
</dialog>
</div>
</div>
<div class="flex flex-col" role="menu">
<div class="relative w-full" data-controller="dropdown-popover" data-dropdown-popover-nested-value="true" data-dropdown-popover-hover-value="true" data-dropdown-popover-flip-class="translate-y-full">
<button class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle" data-menu-target="item" role="menuitem">
Share
<svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
<g fill="currentColor"><path d="m4.25,11c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293.293.293.768,0,1.061l-4.25,4.25c-.146.146-.338.22-.53.22Z" stroke-width="0"></path></g>
</svg>
</button>
<dialog
class="absolute top-0 left-full z-20 ml-2 w-48 rounded-md border border-neutral-200 bg-white text-neutral-900 opacity-0 shadow-md transition-opacity duration-150 ease-out outline-none dark:border-neutral-700/50 dark:bg-neutral-800 dark:text-neutral-100"
data-controller="menu"
data-dropdown-popover-target="menu"
data-action="keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Email link</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Messages</button>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">Notes</button>
</div>
</dialog>
</div>
</div>
<div class="my-1 border-t border-neutral-100 dark:border-neutral-700"></div>
<button class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem">
Save
<span class="ml-auto text-xs text-neutral-500">⌘S</span>
</button>
</div>
</dialog>
</div>
</div>
Lazy loading dropdown
A dropdown that loads its content only when opened, improving performance for complex menus. Uses data-dropdown-popover-lazy-load-value="true"
.
<!-- Lazy loading dropdown example -->
<div class="relative w-fit" data-controller="dropdown-popover" data-dropdown-popover-lazy-load-value="true">
<button class="outline-none flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle">
Lazy Loaded Menu
</button>
<!-- Template that contains content to be loaded only when dropdown is opened -->
<template data-dropdown-popover-target="template">
<div class="flex flex-col p-1" role="menu">
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><circle cx="9" cy="4.5" r="3.5"></circle><path d="M9,9c-2.764,0-5.274,1.636-6.395,4.167-.257,.58-.254,1.245,.008,1.825,.268,.591,.777,1.043,1.399,1.239,1.618,.51,3.296,.769,4.987,.769s3.369-.259,4.987-.769c.622-.196,1.132-.648,1.399-1.239,.262-.58,.265-1.245,.008-1.825-1.121-2.531-3.631-4.167-6.395-4.167Z" fill="currentColor"></path></g></svg></span>
Profile
</a>
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M16.25,4.5h-2.357c-.335-1.29-1.5-2.25-2.893-2.25s-2.558,.96-2.893,2.25H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h6.357c.335,1.29,1.5,2.25,2.893,2.25s2.558-.96,2.893-2.25h2.357c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Zm-5.25,2.25c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5,.673,1.5,1.5-.673,1.5-1.5,1.5Z"></path><path d="M16.25,12h-6.357c-.335-1.29-1.5-2.25-2.893-2.25s-2.558,.96-2.893,2.25H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h2.357c.335,1.29,1.5,2.25,2.893,2.25s2.558-.96,2.893-2.25h6.357c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z" fill="currentColor"></path></g></svg></span>
Settings
</a>
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M17,5.75c0-1.517-1.233-2.75-2.75-2.75H3.75c-1.517,0-2.75,1.233-2.75,2.75v.75H17v-.75Z"></path><path d="M1,12.25c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75v-4.25H1v4.25Zm11.75-1.75h1c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75h-1c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75Zm-8.5,0h3c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75h-3c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75Z" fill="currentColor"></path></g></svg></span>
Billing
</a>
<a href="javascript:void(0)" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" data-menu-target="item" role="menuitem" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M6.188,7.951l-1.404-.525c.457-1.223,1.42-2.186,2.643-2.643l.525,1.405c-.815,.305-1.458,.947-1.764,1.763Z"></path><path d="M11.812,7.951c-.306-.815-.948-1.458-1.764-1.763l.525-1.405c1.223,.457,2.186,1.42,2.643,2.643l-1.404,.525Z"></path><path d="M10.574,13.217l-.525-1.405c.815-.305,1.458-.947,1.764-1.763l1.404,.525c-.457,1.223-1.42,2.186-2.643,2.643Z"></path><path d="M7.426,13.217c-1.223-.457-2.186-1.42-2.643-2.643l1.404-.525c.306,.815,.948,1.458,1.764,1.763l-.525,1.405Z"></path><path d="M6.202,16.497c-2.174-.812-3.887-2.524-4.698-4.699l1.404-.524c.66,1.767,2.052,3.159,3.819,3.818l-.525,1.405Z"></path><path d="M11.798,16.497l-.525-1.405c1.768-.66,3.159-2.051,3.819-3.818l1.404,.524c-.812,2.175-2.524,3.888-4.698,4.699Z"></path><path d="M15.091,6.727c-.658-1.767-2.05-3.159-3.818-3.819l.525-1.405c2.175,.812,3.888,2.525,4.699,4.7l-1.406,.524Z"></path><path d="M2.908,6.727l-1.404-.524c.812-2.175,2.524-3.888,4.698-4.699l.525,1.405c-1.768,.66-3.159,2.051-3.819,3.818Z"></path><path d="M10.312,6.237c-.087,0-.176-.015-.263-.047-.675-.251-1.429-.251-2.098,0-.188,.069-.394,.062-.574-.021-.181-.083-.321-.233-.392-.42l-1.399-3.749c-.069-.186-.062-.393,.021-.574,.083-.181,.234-.322,.42-.391,1.902-.71,4.045-.71,5.947,0,.388,.145,.585,.577,.44,.965l-1.399,3.75c-.113,.302-.399,.488-.703,.488Z" fill="currentColor"></path><path d="M16.263,12.46c-.087,0-.176-.015-.263-.047l-3.749-1.399c-.388-.145-.585-.577-.44-.965,.126-.336,.189-.689,.189-1.049,0-.362-.063-.714-.188-1.047-.07-.187-.062-.393,.02-.575,.083-.181,.233-.322,.42-.392l3.749-1.399c.393-.145,.82,.053,.965,.44,.355,.949,.535,1.95,.535,2.973s-.18,2.024-.535,2.973c-.112,.301-.398,.487-.702,.487Z" fill="currentColor"></path><path d="M9,17.5c-1.022,0-2.022-.18-2.974-.535-.388-.145-.585-.577-.44-.965l1.399-3.75c.146-.388,.576-.585,.966-.44,.675,.251,1.429,.251,2.098,0,.187-.07,.393-.063,.574,.021,.181,.083,.321,.233,.392,.42l1.399,3.749c.069,.186,.062,.393-.021,.574-.083,.181-.234,.322-.42,.391-.951,.355-1.951,.535-2.974,.535Z" fill="currentColor"></path><path d="M1.737,12.46c-.304,0-.59-.186-.702-.487-.355-.949-.535-1.95-.535-2.973s.18-2.024,.535-2.973c.145-.387,.574-.584,.965-.44l3.749,1.399c.388,.145,.585,.577,.44,.965-.126,.336-.189,.689-.189,1.049,0,.362,.063,.714,.188,1.047,.07,.187,.062,.393-.02,.575-.083,.181-.233,.322-.42,.392l-3.749,1.399c-.087,.032-.176,.047-.263,.047Z" fill="currentColor"></path></g></svg></span>
Support & docs
</a>
<div class="my-1 border-t border-neutral-100 dark:border-neutral-700"></div>
<a href="javascript:void(0)" data-menu-target="item" role="menuitem" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-neutral-700 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-100 dark:focus:bg-neutral-700/50" tabindex="-1">
<span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M11.75,11.5c-.414,0-.75,.336-.75,.75v2.5c0,.138-.112,.25-.25,.25H5.448l1.725-1.069c.518-.322,.827-.878,.827-1.487V5.557c0-.609-.31-1.166-.827-1.487l-1.725-1.069h5.302c.138,0,.25,.112,.25,.25v2.5c0,.414,.336,.75,.75,.75s.75-.336,.75-.75V3.25c0-.965-.785-1.75-1.75-1.75H4.25c-.965,0-1.75,.785-1.75,1.75V14.75c0,.965,.785,1.75,1.75,1.75h6.5c.965,0,1.75-.785,1.75-1.75v-2.5c0-.414-.336-.75-.75-.75Z" fill="currentColor"></path><path d="M17.78,8.47l-2.75-2.75c-.293-.293-.768-.293-1.061,0s-.293,.768,0,1.061l1.47,1.47h-4.189c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h4.189l-1.47,1.47c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l2.75-2.75c.293-.293,.293-.768,0-1.061Z"></path></g></svg></span>
Logout
</a>
</div>
</template>
<!-- The dialog element that will be populated from the template -->
<dialog
class="outline-none absolute z-10 w-full rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
style="margin-right: initial;"
data-controller="menu"
data-dropdown-popover-target="menu"
autofocus="false"
data-action="click@document->dropdown-popover#closeOnClickOutside
close->menu#reset
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<!-- Content placeholder that will be replaced by template contents -->
<div data-dropdown-popover-content>
<div class="p-4 text-center text-neutral-500">
<svg class="animate-spin h-5 w-5 mx-auto mb-2" 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>
</div>
</dialog>
</div>
Turbo dropdown
In the following example, we're using the dropdown as a popover that loads content from another Rails partial only when opened, improving performance. Uses data-dropdown-popover-lazy-load-value="true"
& data-dropdown-popover-turbo-frame-src-value="<%= dropdown_content_path %>"
.
<div class="relative w-fit" data-controller="dropdown-popover" data-dropdown-popover-turbo-frame-src-value="<%= dropdown_content_path %>" data-dropdown-popover-lazy-load-value="true" data-dropdown-popover-placement-value="top bottom">
<button class="outline-none flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 p-2.5 text-xs font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-dropdown-popover-target="button" data-action="dropdown-popover#toggle">
<img src="https://thispersondoesnotexist.com" alt="User Avatar" class="size-5 rounded-full">
</button>
<!-- Dialog to be populated -->
<dialog
class="outline-none absolute z-10 w-48 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50"
style="margin-right: initial;"
data-controller="menu"
data-dropdown-popover-target="menu"
autofocus="false"
data-action="click@document->dropdown-popover#closeOnClickOutside
close->menu#reset
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div data-dropdown-popover-content>
<div class="p-4 text-center text-neutral-500">
<svg class="animate-spin h-5 w-5 mx-auto mb-2" 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 from server...
</div>
</div>
</dialog>
</div>
# Dropdown content route for Turbo Frame lazy loading
get "/dropdown_content", to: "pages#dropdown_content", as: "dropdown_content"
# Dropdown content action for Turbo Frame lazy loading
def dropdown_content
render partial: "components/dropdown/dropdown_content", layout: false
end
Configuration
Basic Setup
To create a dropdown menu, wrap your trigger element with the dropdown-popover controller and add a dialog element as the menu:
<div data-controller="dropdown-popover">
<button data-dropdown-popover-target="button" data-action="dropdown-popover#toggle">
Open Menu
</button>
<dialog
data-dropdown-popover-target="menu"
data-controller="menu"
data-action="click@document->dropdown-popover#closeOnClickOutside
keydown.up->menu#prev keydown.down->menu#next
keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
>
<div class="flex flex-col p-1" role="menu">
<button data-menu-target="item" role="menuitem">Action 1</button>
<button data-menu-target="item" role="menuitem">Action 2</button>
</div>
</dialog>
</div>
Targets
The dropdown popover controller uses the following targets:
Target | Controller | Description |
---|---|---|
button
|
dropdown-popover
|
The button that triggers the dropdown |
menu
|
dropdown-popover
|
The dialog element that contains the dropdown menu |
template
|
dropdown-popover
|
Template element for lazy loading content |
item
|
menu
|
Individual menu items for keyboard navigation |
Values
Configure the dropdown behavior with these data attributes:
Value | Type | Default | Description |
---|---|---|---|
autoClose
|
Boolean
|
true
|
Whether to close the menu when clicking on an item |
nested
|
Boolean
|
false
|
Enable nested dropdown behavior |
hover
|
Boolean
|
false
|
Open submenu on hover instead of click |
autoPosition
|
Boolean
|
true
|
Automatically position the menu using Floating UI |
lazyLoad
|
Boolean
|
false
|
Load menu content only when opened |
placement
|
String
|
"bottom-start"
|
Initial placement of the menu (Floating UI placement) |
Features
The dropdown includes the following features:
- Smart positioning: Automatically adjusts position using Floating UI to stay within viewport
- Keyboard navigation: Arrow keys, Enter, and Escape support with menu controller
- Nested submenus: Support for multi-level dropdown menus
- Auto-close: Closes when clicking outside or pressing Escape
- Lazy loading: Load content only when dropdown is opened
- Hover support: Open submenus on hover for faster navigation
- Text search: Type to search and focus menu items
- Form integration: Works with radio buttons, checkboxes, and form submissions
Accessibility
The dropdown is built with accessibility in mind:
- ARIA support: Proper
role="menu"
androle="menuitem"
attributes - Screen reader friendly: Semantic HTML structure with proper labeling
- Keyboard navigation: Full keyboard accessibility with arrow keys
- Escape handling: Close dropdown with Escape key
Nested Submenus
Create nested submenus using the dropdown-popover controller with data-dropdown-popover-nested-value="true"
:
<div class="relative w-full"
data-controller="dropdown-popover"
data-dropdown-popover-nested-value="true"
data-dropdown-popover-flip-class="translate-y-full">
<button data-dropdown-popover-target="button"
data-action="dropdown-popover#toggle"
data-menu-target="item"
role="menuitem">
Submenu
<svg>...</svg>
</button>
<dialog data-controller="menu"
data-dropdown-popover-target="menu">
<div class="flex flex-col p-1" role="menu">
<button data-menu-target="item" role="menuitem">Nested Item 1</button>
<button data-menu-target="item" role="menuitem">Nested Item 2</button>
</div>
</dialog>
</div>
Lazy Loading
Enable lazy loading to improve performance by loading content only when the dropdown is opened:
<div data-controller="dropdown-popover"
data-dropdown-popover-lazy-load-value="true">
<button data-dropdown-popover-target="button">Lazy Menu</button>
<!-- Template with content to load -->
<template data-dropdown-popover-target="template">
<div class="flex flex-col p-1.5" role="menu">
<button data-menu-target="item">Lazy Item 1</button>
<button data-menu-target="item">Lazy Item 2</button>
</div>
</template>
<dialog data-dropdown-popover-target="menu">
<div data-dropdown-popover-content>
<!-- Loading placeholder -->
<div class="p-4 text-center">Loading...</div>
</div>
</dialog>
</div>
JavaScript API
Access the controller programmatically:
// Get the controller instance
const element = document.querySelector('[data-controller="dropdown-popover"]');
const controller = application.getControllerForElementAndIdentifier(element, 'dropdown-popover');
// Show the dropdown
controller.show();
// Close the dropdown
controller.close();
// Toggle the dropdown
controller.toggle();
// Check if dropdown is open
const isOpen = controller.isOpen;