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"
Terminal
npm install @floating-ui/dom
Terminal
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".

Loading...
<!-- 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 %>".

Loading from server...
<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" and role="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;

Table of contents