Inspired by Radix UI

Navbar Rails Components

Navbars with links, dropdowns, and keyboard navigation, mobile support, and nested submenus.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["trigger", "content", "viewport", "indicator", "menu", "background"];

  connect() {
    this.isOpen = false;
    this.currentContentId = null;
    this.closeTimeout = null;
    this.closeAnimationTimeout = null;
    this.transitionTimeout = null;
    this.justOpened = false;
    this.lastMouseX = 0;
    this.lastMouseY = 0;

    // Detect if this is a touch device
    this.isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;

    // Setup click outside listener
    this.handleClickOutside = this.handleClickOutside.bind(this);
    document.addEventListener("click", this.handleClickOutside);

    // Setup keyboard listeners
    this.handleKeydown = this.handleKeydown.bind(this);
    document.addEventListener("keydown", this.handleKeydown);

    // Track mouse position for hover detection
    this.trackMousePosition = this.trackMousePosition.bind(this);
    document.addEventListener("mousemove", this.trackMousePosition);
  }

  disconnect() {
    document.removeEventListener("click", this.handleClickOutside);
    document.removeEventListener("keydown", this.handleKeydown);
    document.removeEventListener("mousemove", this.trackMousePosition);
    if (this.closeTimeout) {
      clearTimeout(this.closeTimeout);
    }
    if (this.closeAnimationTimeout) {
      clearTimeout(this.closeAnimationTimeout);
    }
    if (this.transitionTimeout) {
      clearTimeout(this.transitionTimeout);
    }
  }

  trackMousePosition(event) {
    this.lastMouseX = event.clientX;
    this.lastMouseY = event.clientY;
  }

  toggleMenu(event) {
    const trigger = event.currentTarget;
    const contentId = trigger.dataset.contentId;

    // Clear any pending close timeout
    if (this.closeTimeout) {
      clearTimeout(this.closeTimeout);
      this.closeTimeout = null;
    }

    // If clicking the same trigger, close it
    if (this.isOpen && this.currentContentId === contentId) {
      this.closeMenu();
      return;
    }

    // If a different menu is open, close it first
    if (this.isOpen && this.currentContentId !== contentId) {
      this.closeMenu(false);
    }

    // Open the new menu
    this.openMenu(trigger, contentId);
  }

  handleMouseEnter(event) {
    // Skip hover behavior on touch devices to prevent conflicts with tap/click
    if (this.isTouchDevice) {
      return;
    }

    const trigger = event.currentTarget;
    const contentId = trigger.dataset.contentId;

    // Clear any pending close timeouts
    if (this.closeTimeout) {
      clearTimeout(this.closeTimeout);
      this.closeTimeout = null;
    }

    // Clear any pending close animation
    if (this.closeAnimationTimeout) {
      clearTimeout(this.closeAnimationTimeout);
      this.closeAnimationTimeout = null;
    }

    // If viewport is in closing state, immediately reset it
    if (this.hasViewportTarget && this.viewportTarget.dataset.state === "closing") {
      this.viewportTarget.dataset.state = "open";
      // Also restore isOpen state since we're preventing the close
      this.isOpen = true;
    }

    // If the same menu is already open, do nothing
    if (this.isOpen && this.currentContentId === contentId) {
      return;
    }

    // If a different menu is open, smoothly transition to the new one
    if (this.isOpen && this.currentContentId !== contentId) {
      this.transitionToMenu(trigger, contentId);
      return;
    }

    // Open the new menu
    this.openMenu(trigger, contentId);
  }

  handleMouseLeave(event) {
    // Skip hover behavior on touch devices to prevent conflicts with tap/click
    if (this.isTouchDevice) {
      return;
    }

    // Clear any existing close timeout
    if (this.closeTimeout) {
      clearTimeout(this.closeTimeout);
    }

    // Close after a delay, but verify mouse is actually outside navbar area
    const delay = this.justOpened ? 350 : 150;

    this.closeTimeout = setTimeout(() => {
      // Check if mouse is still over the viewport or any trigger
      if (this.isMouseOverNavbar()) {
        return;
      }
      this.closeMenu();
    }, delay);
  }

  isMouseOverNavbar() {
    const mouseX = this.lastMouseX;
    const mouseY = this.lastMouseY;

    // Check if mouse is over the viewport
    if (this.hasViewportTarget) {
      const viewportRect = this.viewportTarget.getBoundingClientRect();
      const isOverViewport =
        mouseX >= viewportRect.left &&
        mouseX <= viewportRect.right &&
        mouseY >= viewportRect.top &&
        mouseY <= viewportRect.bottom;

      if (isOverViewport) {
        return true;
      }
    }

    // Check if mouse is over any trigger
    return this.triggerTargets.some((trigger) => {
      const triggerRect = trigger.getBoundingClientRect();
      return (
        mouseX >= triggerRect.left &&
        mouseX <= triggerRect.right &&
        mouseY >= triggerRect.top &&
        mouseY <= triggerRect.bottom
      );
    });
  }

  cancelClose(event) {
    // Skip hover behavior on touch devices to prevent conflicts with tap/click
    if (this.isTouchDevice) {
      return;
    }

    // Cancel close when mouse enters the viewport
    if (this.closeTimeout) {
      clearTimeout(this.closeTimeout);
      this.closeTimeout = null;
    }

    // Cancel any pending close animation
    if (this.closeAnimationTimeout) {
      clearTimeout(this.closeAnimationTimeout);
      this.closeAnimationTimeout = null;
    }

    // If viewport is in closing state, immediately reset it
    if (this.hasViewportTarget && this.viewportTarget.dataset.state === "closing") {
      this.viewportTarget.dataset.state = "open";
    }
  }

  openMenu(trigger, contentId) {
    const content = this.contentTargets.find((c) => c.id === contentId);
    if (!content) return;

    // Clear any pending close timeouts to prevent interference from mouse events
    if (this.closeTimeout) {
      clearTimeout(this.closeTimeout);
      this.closeTimeout = null;
    }

    if (this.closeAnimationTimeout) {
      clearTimeout(this.closeAnimationTimeout);
      this.closeAnimationTimeout = null;
    }

    this.isOpen = true;
    this.currentContentId = contentId;

    // Set flag to prevent mouse events from closing menu immediately after opening
    this.justOpened = true;
    setTimeout(() => {
      this.justOpened = false;
    }, 300);

    // Clean up ALL content first - ensure no leftover styles
    this.contentTargets.forEach((c) => {
      if (c.id !== contentId) {
        c.classList.add("hidden");
        c.dataset.state = "closed";
      }
      // Reset any transition styles from previous animations
      c.style.position = "";
      c.style.width = "";
      c.style.top = "";
      c.style.left = "";
      c.style.opacity = "";
      c.style.transition = "";
      c.style.filter = "";
    });

    // Mark all other triggers as closed
    this.triggerTargets.forEach((t) => {
      if (t.dataset.contentId !== contentId) {
        t.dataset.state = "closed";
      }
    });

    // Mark trigger as active
    trigger.dataset.state = "open";

    // CRITICAL: Clear viewport dimensions from any previous menu
    if (this.hasViewportTarget) {
      this.viewportTarget.style.width = "";
      this.viewportTarget.style.height = "";
      this.viewportTarget.style.transition = "";
      // On mobile, clear left style to allow CSS centering
      const isMobile = window.innerWidth < 640;
      if (isMobile) {
        this.viewportTarget.style.left = "";
      }
    }

    if (this.hasBackgroundTarget) {
      this.backgroundTarget.style.width = "";
      this.backgroundTarget.style.height = "";
      this.backgroundTarget.style.overflow = "";
      this.backgroundTarget.style.transition = "";
    }

    // Show content first (before viewport for proper height calculation)
    content.classList.remove("hidden");
    content.dataset.state = "open";

    // Position viewport and indicator before showing
    if (this.hasIndicatorTarget && this.hasViewportTarget) {
      // Find the effective trigger for indicator positioning
      // If opening from within mobile menu, use the hamburger button instead
      const effectiveTrigger = this.getEffectiveTrigger(trigger, contentId);

      // Disable transitions for positioning to prevent horizontal slide
      this.viewportTarget.style.transition = "none";

      // Position immediately
      this.positionIndicator(effectiveTrigger);

      // Re-enable transition and show viewport with animation
      requestAnimationFrame(() => {
        this.viewportTarget.style.transition = "";

        // Show viewport on next frame to ensure transition is active
        requestAnimationFrame(() => {
          this.viewportTarget.dataset.state = "open";
        });
      });
    } else if (this.hasViewportTarget) {
      // If no indicator, just show viewport
      this.viewportTarget.dataset.state = "open";
    }
  }

  getEffectiveTrigger(trigger, contentId) {
    // Check if the trigger is inside the mobile menu content
    const mobileMenuContent = document.getElementById("mobile-menu-content");
    if (mobileMenuContent && mobileMenuContent.contains(trigger)) {
      // Find the hamburger menu button (the trigger for mobile-menu-content)
      const hamburgerTrigger = this.triggerTargets.find((t) => t.dataset.contentId === "mobile-menu-content");
      if (hamburgerTrigger) {
        return hamburgerTrigger;
      }
    }

    return trigger;
  }

  transitionToMenu(trigger, contentId) {
    // Store the old content reference BEFORE any cleanup
    const oldContentId = this.currentContentId;
    const newContent = this.contentTargets.find((c) => c.id === contentId);
    const oldContent = this.contentTargets.find((c) => c.id === oldContentId);
    if (!newContent || !this.hasViewportTarget) return;

    // Update current state immediately to prevent race conditions
    this.currentContentId = contentId;

    // Set flag to prevent mouse events from closing menu during transition
    this.justOpened = true;
    setTimeout(() => {
      this.justOpened = false;
    }, 400);

    // Clear any pending transition cleanup to prevent interference
    if (this.transitionTimeout) {
      clearTimeout(this.transitionTimeout);
      this.transitionTimeout = null;

      // Immediately clean up any in-progress transition state
      // Reset all content that might be mid-transition (except the ones we're transitioning between)
      this.contentTargets.forEach((content) => {
        if (content.id !== contentId && content.id !== oldContentId) {
          content.classList.add("hidden");
          content.dataset.state = "closed";
          content.style.position = "";
          content.style.width = "";
          content.style.top = "";
          content.style.left = "";
          content.style.opacity = "";
          content.style.transition = "";
          content.style.filter = "";
        }
      });

      // Clean up old content that was mid-transition
      if (oldContent) {
        // Hide it temporarily so it doesn't affect viewport measurements
        oldContent.classList.add("hidden");
        oldContent.style.position = "";
        oldContent.style.width = "";
        oldContent.style.top = "";
        oldContent.style.left = "";
        oldContent.style.opacity = "";
        oldContent.style.transition = "";
        oldContent.style.filter = "";
      }

      // DON'T reset viewport/background dimensions - we need them for smooth measurement
      // Just remove transitions so they don't interfere
      this.viewportTarget.style.transition = "";

      if (this.hasBackgroundTarget) {
        this.backgroundTarget.style.overflow = "";
        this.backgroundTarget.style.transition = "";
      }

      if (this.hasIndicatorTarget) {
        this.indicatorTarget.style.transition = "";
      }

      // Force a reflow to ensure all cleanup styles are applied before starting new transition
      void this.viewportTarget.offsetHeight;
    }

    // Update trigger states
    this.triggerTargets.forEach((t) => {
      t.dataset.state = t.dataset.contentId === contentId ? "open" : "closed";
    });

    // Use requestAnimationFrame to ensure cleanup is rendered before measuring
    requestAnimationFrame(() => {
      // Get current viewport position
      const currentLeft = parseFloat(this.viewportTarget.style.left) || 0;

      // IMPORTANT: Measure current dimensions BEFORE clearing to get accurate starting point
      // If viewport has explicit dimensions, use those; otherwise use the computed size
      let currentWidth, currentHeight;

      if (this.viewportTarget.style.width && this.viewportTarget.style.height) {
        // Use the explicit dimensions from previous transition
        currentWidth = this.viewportTarget.offsetWidth;
        currentHeight = this.viewportTarget.offsetHeight;
      } else {
        // No explicit dimensions - measure from current content
        // This happens on the first transition or after cleanup has completed
        const currentContent = oldContent && !oldContent.classList.contains("hidden") ? oldContent : null;
        if (currentContent) {
          currentWidth = currentContent.offsetWidth;
          currentHeight = currentContent.offsetHeight;
        } else {
          // Fallback to viewport size
          currentWidth = this.viewportTarget.offsetWidth;
          currentHeight = this.viewportTarget.offsetHeight;
        }
      }

      // NOW clear viewport size constraints so new content can measure at natural size
      this.viewportTarget.style.width = "";
      this.viewportTarget.style.height = "";

      if (this.hasBackgroundTarget) {
        this.backgroundTarget.style.width = "";
        this.backgroundTarget.style.height = "";
      }

      // Ensure new content has no lingering transition styles
      newContent.style.position = "";
      newContent.style.width = "";
      newContent.style.top = "";
      newContent.style.left = "";
      newContent.style.filter = "";
      newContent.style.transition = "";

      newContent.classList.remove("hidden");
      newContent.style.opacity = "0";
      newContent.style.position = "absolute";
      newContent.dataset.state = "open";

      // Force a reflow before measuring
      void newContent.offsetHeight;

      // Force layout and measure new dimensions at natural size
      const newWidth = newContent.offsetWidth;
      const newHeight = newContent.offsetHeight;

      // Reset new content positioning
      newContent.style.position = "";

      // Hide new content again (will show it with animation)
      newContent.classList.add("hidden");

      // Calculate new position to determine movement direction
      const effectiveTrigger = this.getEffectiveTrigger(trigger, contentId);
      const triggerRect = effectiveTrigger.getBoundingClientRect();
      const parentRect = this.viewportTarget.parentElement.getBoundingClientRect();
      const align = effectiveTrigger.dataset.align || "center";

      // Check if we're on mobile
      const isMobile = window.innerWidth < 640;

      let newLeft;
      let newRelativeLeft;

      if (isMobile) {
        // On mobile, CSS handles centering - don't override it
        // Just use current left position for smooth transition
        newRelativeLeft = parseFloat(this.viewportTarget.style.left) || 0;
      } else {
        // On desktop, position based on trigger alignment
        switch (align) {
          case "start":
            newLeft = triggerRect.left;
            break;
          case "end":
            newLeft = triggerRect.right - newWidth;
            break;
          case "center":
          default:
            const triggerCenter = triggerRect.left + triggerRect.width / 2;
            newLeft = triggerCenter - newWidth / 2;
            break;
        }
        newRelativeLeft = newLeft - parentRect.left;
      }

      // Set explicit dimensions on viewport for smooth transition
      this.viewportTarget.style.width = `${currentWidth}px`;
      this.viewportTarget.style.height = `${currentHeight}px`;

      // On mobile, don't animate left since CSS centering handles it
      if (isMobile) {
        this.viewportTarget.style.transition =
          "width 300ms cubic-bezier(0.22, 0.61, 0.36, 1), height 300ms cubic-bezier(0.22, 0.61, 0.36, 1)";
      } else {
        this.viewportTarget.style.transition =
          "left 250ms cubic-bezier(0.22, 0.61, 0.36, 1), width 300ms cubic-bezier(0.22, 0.61, 0.36, 1), height 300ms cubic-bezier(0.22, 0.61, 0.36, 1)";
      }

      // Also set dimensions on background container if it exists
      if (this.hasBackgroundTarget) {
        this.backgroundTarget.style.width = `${currentWidth}px`;
        this.backgroundTarget.style.height = `${currentHeight}px`;
        this.backgroundTarget.style.overflow = "hidden";
        this.backgroundTarget.style.transition =
          "width 300ms cubic-bezier(0.22, 0.61, 0.36, 1), height 300ms cubic-bezier(0.22, 0.61, 0.36, 1)";
      }

      // Position the content absolutely within viewport during transition
      if (oldContent) {
        oldContent.style.position = "absolute";
        oldContent.style.width = "100%";
        oldContent.style.top = "0";
        oldContent.style.left = "0";
        oldContent.style.filter = "blur(0px)";
      }

      // Show new content positioned absolutely
      newContent.classList.remove("hidden");
      newContent.style.position = "absolute";
      newContent.style.width = `${newWidth}px`;
      newContent.style.top = "0";
      newContent.style.left = "0";
      newContent.style.opacity = "0";

      // Position the viewport and indicator for the new content
      if (this.hasIndicatorTarget) {
        // Add smooth transition to indicator movement
        this.indicatorTarget.style.transition = "left 250ms cubic-bezier(0.22, 0.61, 0.36, 1)";
        this.positionIndicator(effectiveTrigger, newWidth);
      }

      // Use another requestAnimationFrame to ensure styles are applied before animating
      requestAnimationFrame(() => {
        // Transition viewport dimensions
        this.viewportTarget.style.width = `${newWidth}px`;
        this.viewportTarget.style.height = `${newHeight}px`;

        // Also transition background dimensions if it exists
        if (this.hasBackgroundTarget) {
          this.backgroundTarget.style.width = `${newWidth}px`;
          this.backgroundTarget.style.height = `${newHeight}px`;
        }

        // Blur and fade out old content
        if (oldContent) {
          oldContent.style.transition =
            "opacity 250ms cubic-bezier(0.22, 0.61, 0.36, 1), filter 250ms cubic-bezier(0.22, 0.61, 0.36, 1)";
          oldContent.style.opacity = "0";
          oldContent.style.filter = "blur(4px)";
        }

        // Fade in new content
        newContent.style.transition = "opacity 250ms cubic-bezier(0.22, 0.61, 0.36, 1) 50ms";
        newContent.style.opacity = "1";

        // After transition, clean up
        this.transitionTimeout = setTimeout(() => {
          // Reset viewport to auto dimensions
          this.viewportTarget.style.width = "";
          this.viewportTarget.style.height = "";
          this.viewportTarget.style.transition = "";

          // Reset background dimensions if it exists
          if (this.hasBackgroundTarget) {
            this.backgroundTarget.style.width = "";
            this.backgroundTarget.style.height = "";
            this.backgroundTarget.style.overflow = "";
            this.backgroundTarget.style.transition = "";
          }

          // Reset indicator transition
          if (this.hasIndicatorTarget) {
            this.indicatorTarget.style.transition = "";
          }

          // Reset new content
          newContent.style.position = "";
          newContent.style.width = "";
          newContent.style.top = "";
          newContent.style.left = "";
          newContent.style.opacity = "";
          newContent.style.transition = "";

          // Hide and reset old content
          if (oldContent) {
            oldContent.classList.add("hidden");
            oldContent.dataset.state = "closed";
            oldContent.style.position = "";
            oldContent.style.width = "";
            oldContent.style.top = "";
            oldContent.style.left = "";
            oldContent.style.opacity = "";
            oldContent.style.transition = "";
            oldContent.style.filter = "";
          }

          this.transitionTimeout = null;
        }, 300);
      });
    });
  }

  closeMenu(animate = true) {
    if (!this.isOpen) return;

    // Clear any pending transition cleanup
    if (this.transitionTimeout) {
      clearTimeout(this.transitionTimeout);
      this.transitionTimeout = null;
    }

    const closingContentId = this.currentContentId; // Save before clearing
    const content = this.contentTargets.find((c) => c.id === closingContentId);
    const trigger = this.triggerTargets.find((t) => t.dataset.contentId === closingContentId);

    if (trigger) {
      trigger.dataset.state = "closed";
    }

    // Mark as closed immediately so new hovers know we're closing
    this.isOpen = false;
    this.justOpened = false;

    // Hide viewport with animation
    if (this.hasViewportTarget) {
      if (animate) {
        // Set closing state for CSS animation
        this.viewportTarget.dataset.state = "closing";

        // Hide after animation completes
        this.closeAnimationTimeout = setTimeout(() => {
          this.viewportTarget.dataset.state = "closed";

          // Hide content after viewport is hidden
          if (content) {
            content.classList.add("hidden");
            content.dataset.state = "closed";
          }

          // Reset viewport position after animation completes
          // On mobile, clear the left style to allow CSS centering; on desktop set to 0
          const isMobile = window.innerWidth < 640;
          this.viewportTarget.style.left = isMobile ? "" : "0px";
          if (this.hasIndicatorTarget) {
            this.indicatorTarget.style.left = "0px";
          }

          // Clear currentContentId ONLY after animation completes
          this.currentContentId = null;
          this.closeAnimationTimeout = null;
        }, 200);
      } else {
        this.viewportTarget.dataset.state = "closed";

        if (content) {
          content.classList.add("hidden");
          content.dataset.state = "closed";
        }

        // Reset viewport position immediately when not animating
        // On mobile, clear the left style to allow CSS centering; on desktop set to 0
        const isMobile = window.innerWidth < 640;
        this.viewportTarget.style.left = isMobile ? "" : "0px";
        if (this.hasIndicatorTarget) {
          this.indicatorTarget.style.left = "0px";
        }

        // Clear currentContentId immediately when not animating
        this.currentContentId = null;
      }
    }
  }

  handleClickOutside(event) {
    if (!this.isOpen) return;

    // Check if click is outside the navbar
    if (!this.element.contains(event.target)) {
      this.closeMenu();
    }
  }

  handleKeydown(event) {
    // Handle Escape key
    if (event.key === "Escape" && this.isOpen) {
      event.preventDefault();
      this.closeMenu();
      // Return focus to the active trigger
      const trigger = this.triggerTargets.find((t) => t.dataset.contentId === this.currentContentId);
      if (trigger) trigger.focus();
      return;
    }

    // Only handle arrow keys and Tab when menu is open
    if (!this.isOpen) return;

    const focusableItems = this.getFocusableItems();
    if (focusableItems.length === 0) return;

    const currentIndex = focusableItems.indexOf(document.activeElement);

    // Handle Arrow Down or Arrow Right - next item
    if (event.key === "ArrowDown" || event.key === "ArrowRight") {
      event.preventDefault();
      const nextIndex = currentIndex < focusableItems.length - 1 ? currentIndex + 1 : 0;
      focusableItems[nextIndex].focus();
    }

    // Handle Arrow Up or Arrow Left - previous item
    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
      event.preventDefault();
      const prevIndex = currentIndex > 0 ? currentIndex - 1 : focusableItems.length - 1;
      focusableItems[prevIndex].focus();
    }

    // Handle Tab key - close menu and move to next navbar item
    if (event.key === "Tab" && !event.shiftKey) {
      event.preventDefault();

      // Get all visible, focusable elements in the navbar menu (not inside dropdown content)
      const menuElement = this.hasMenuTarget ? this.menuTarget : this.element.querySelector("ul");
      if (menuElement) {
        // Only get direct children of the menu, excluding items inside dropdown content
        const visibleNavItems = Array.from(
          menuElement.querySelectorAll(":scope > li > a, :scope > li > button")
        ).filter((el) => {
          // Check if element is visible
          const rect = el.getBoundingClientRect();
          return rect.width > 0 && rect.height > 0;
        });

        // Find the currently active trigger (that opened this menu) among visible items
        // Important: filter triggers to only visible ones to handle responsive layouts
        const visibleTriggers = this.triggerTargets.filter((t) => {
          const rect = t.getBoundingClientRect();
          return rect.width > 0 && rect.height > 0 && t.dataset.contentId === this.currentContentId;
        });

        const currentTrigger = visibleTriggers[0]; // Get the first visible trigger for this content

        this.closeMenu(false); // Close without animation for immediate focus shift

        const currentIndex = currentTrigger ? visibleNavItems.indexOf(currentTrigger) : -1;

        if (currentIndex !== -1 && currentIndex + 1 < visibleNavItems.length) {
          // Focus next visible nav item
          visibleNavItems[currentIndex + 1].focus();
        } else {
          // If no next item in navbar, tab out to content after navbar
          const allFocusable = Array.from(
            document.querySelectorAll(
              'a[href]:not([disabled]), button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
            )
          );
          const nextElement = allFocusable.find((el) => {
            return !this.element.contains(el);
          });
          if (nextElement) nextElement.focus();
        }
      }
    }

    // Handle Shift+Tab - close menu and move to previous navbar item
    if (event.key === "Tab" && event.shiftKey) {
      event.preventDefault();

      // Get all visible, focusable elements in the navbar menu (not inside dropdown content)
      const menuElement = this.hasMenuTarget ? this.menuTarget : this.element.querySelector("ul");
      if (menuElement) {
        // Only get direct children of the menu, excluding items inside dropdown content
        const visibleNavItems = Array.from(
          menuElement.querySelectorAll(":scope > li > a, :scope > li > button")
        ).filter((el) => {
          // Check if element is visible
          const rect = el.getBoundingClientRect();
          return rect.width > 0 && rect.height > 0;
        });

        // Find the currently active trigger (that opened this menu) among visible items
        const visibleTriggers = this.triggerTargets.filter((t) => {
          const rect = t.getBoundingClientRect();
          return rect.width > 0 && rect.height > 0 && t.dataset.contentId === this.currentContentId;
        });

        const currentTrigger = visibleTriggers[0]; // Get the first visible trigger for this content

        this.closeMenu(false); // Close without animation for immediate focus shift

        const currentIndex = currentTrigger ? visibleNavItems.indexOf(currentTrigger) : -1;

        if (currentIndex > 0) {
          // Focus previous visible nav item
          visibleNavItems[currentIndex - 1].focus();
        } else {
          // If at first item in navbar, tab out to content before navbar
          const allFocusable = Array.from(
            document.querySelectorAll(
              'a[href]:not([disabled]), button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
            )
          );
          // Find the last focusable element before the navbar
          const navbarStart = this.element.getBoundingClientRect().top;
          const navbarElements = Array.from(this.element.querySelectorAll("*"));
          const previousElement = allFocusable.reverse().find((el) => {
            const rect = el.getBoundingClientRect();
            return rect.bottom <= navbarStart || (!navbarElements.includes(el) && !this.element.contains(el));
          });
          if (previousElement) previousElement.focus();
        }
      }
    }
  }

  getFocusableItems() {
    if (!this.isOpen) return [];

    const content = this.contentTargets.find((c) => c.id === this.currentContentId);
    if (!content) return [];

    return Array.from(content.querySelectorAll("a[href]:not([disabled]), button:not([disabled])"));
  }

  handleTriggerKeydown(event) {
    const trigger = event.currentTarget;
    const contentId = trigger.dataset.contentId;

    // Open menu and focus first item on Enter, Space, ArrowDown, or ArrowRight
    if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowRight") {
      event.preventDefault();

      // Open menu if not open
      if (!this.isOpen || this.currentContentId !== contentId) {
        this.openMenu(trigger, contentId);
      }

      // Focus first item after menu opens
      requestAnimationFrame(() => {
        const focusableItems = this.getFocusableItems();
        if (focusableItems.length > 0) {
          focusableItems[0].focus();
        }
      });
    }

    // Open menu and focus last item on ArrowUp or ArrowLeft
    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
      event.preventDefault();

      // Open menu if not open
      if (!this.isOpen || this.currentContentId !== contentId) {
        this.openMenu(trigger, contentId);
      }

      // Focus last item after menu opens
      requestAnimationFrame(() => {
        const focusableItems = this.getFocusableItems();
        if (focusableItems.length > 0) {
          focusableItems[focusableItems.length - 1].focus();
        }
      });
    }
  }

  positionIndicator(trigger, providedWidth = null) {
    if (!this.hasIndicatorTarget || !this.hasViewportTarget) return;

    const triggerRect = trigger.getBoundingClientRect();

    // Get viewport width (use provided width during transitions, otherwise read from DOM)
    const viewportWidth = providedWidth !== null ? providedWidth : this.viewportTarget.offsetWidth;

    // Check if we're on mobile (screen width < 640px, sm breakpoint)
    const isMobile = window.innerWidth < 640;

    let viewportLeft;
    let indicatorLeft;

    if (isMobile) {
      // On mobile, CSS handles centering with left-1/2 -translate-x-1/2
      // Don't override the CSS positioning
      // Just hide the indicator and return early
      this.indicatorTarget.style.opacity = "0";
      return;
    } else {
      // On desktop, position relative to trigger
      // Get alignment from trigger's data attribute (defaults to "center")
      const align = trigger.dataset.align || "center";

      // Show indicator on desktop
      this.indicatorTarget.style.opacity = "1";

      // Calculate positions based on alignment
      switch (align) {
        case "start":
          // Align viewport left edge with trigger left edge
          viewportLeft = triggerRect.left;
          // Position indicator at the trigger center relative to viewport
          indicatorLeft = triggerRect.width / 2 - 20;
          break;

        case "end":
          // Align viewport right edge with trigger right edge
          viewportLeft = triggerRect.right - viewportWidth;
          // Position indicator at the trigger center relative to viewport
          const offsetFromRight = triggerRect.width / 2;
          indicatorLeft = viewportWidth - offsetFromRight - 20;
          break;

        case "center":
        default:
          // Center viewport on trigger
          const triggerCenter = triggerRect.left + triggerRect.width / 2;
          viewportLeft = triggerCenter - viewportWidth / 2;
          // Position indicator at center of viewport
          indicatorLeft = viewportWidth / 2 - 20;
          break;
      }
    }

    // Calculate position relative to the parent element
    const parentRect = this.viewportTarget.parentElement.getBoundingClientRect();
    const relativeLeft = viewportLeft - parentRect.left;

    // Position the viewport
    this.viewportTarget.style.left = `${relativeLeft}px`;

    // Position the indicator
    // w-10 = 40px (2.5rem), so subtract 20px to center the arrow
    this.indicatorTarget.style.left = `${indicatorLeft}px`;
  }
}

Examples

Basic Navbar

A responsive navbar with dropdown menus featuring smooth animations. Click on the menu items to see the dropdown content.

<nav class="flex w-full justify-center" data-controller="navbar">
  <div class="relative">
    <!-- Menu List -->
    <ul data-navbar-target="menu" class="m-0 flex list-none items-center gap-1 rounded-lg border border-neutral-200 bg-white p-1 shadow dark:border-neutral-700 dark:bg-neutral-800">

      <li>
        <a href="/" class="block rounded-md px-3 py-2 text-sm font-medium text-neutral-700 no-underline transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Home
        </a>
      </li>

      <!-- Components Dropdown -->
      <li class="hidden sm:block">
        <button type="button" data-navbar-target="trigger" data-content-id="components-content" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Components
          <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
          </svg>
        </button>
      </li>

      <!-- Resources Dropdown -->
      <li class="hidden sm:block">
        <button type="button" data-navbar-target="trigger" data-content-id="resources-content" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Resources
          <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
          </svg>
        </button>
      </li>

      <li class="hidden sm:block">
        <a href="/changelog" class="block rounded-md px-3 py-2 text-sm font-medium text-neutral-700 no-underline transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Changelog
        </a>
      </li>

      <li class="relative sm:hidden">
        <button type="button" data-navbar-target="trigger" data-content-id="mobile-menu-content" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-1 rounded-md px-2 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="2.25" y1="9" x2="15.75" y2="9"></line><line x1="2.25" y1="3.75" x2="15.75" y2="3.75"></line><line x1="2.25" y1="14.25" x2="15.75" y2="14.25"></line></g></svg>
        </button>
      </li>
    </ul>

    <!-- Viewport Container -->
    <div data-navbar-target="viewport" data-action="mouseenter->navbar#cancelClose mouseleave->navbar#handleMouseLeave" data-state="closed" class="absolute top-full z-50 mt-2 origin-top transition-all duration-200 ease-out data-[state=closed]:pointer-events-none data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=closing]:pointer-events-none data-[state=closing]:scale-95 data-[state=closing]:opacity-0 data-[state=open]:pointer-events-auto data-[state=open]:scale-100 data-[state=open]:opacity-100 left-1/2 -translate-x-1/2 sm:left-0 sm:translate-x-0">
      <!-- Indicator Arrow -->
      <div data-navbar-target="indicator" class="pointer-events-none absolute -top-1 z-10 flex h-2 w-10 items-end justify-center overflow-visible transition-opacity duration-200">
        <div class="h-2 w-2 rotate-45 border-t border-l border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"></div>
      </div>

      <div data-navbar-target="background" class="relative z-0 rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-800">
        <!-- Components Content -->
        <div id="components-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="grid grid-cols-1 gap-2 p-5 w-[calc(100vw-2rem)] sm:w-[500px] sm:grid-cols-[0.75fr_1fr]">
            <!-- Featured Callout -->
            <a href="/" class="row-span-3 flex h-full w-full cursor-pointer flex-col justify-end rounded-lg bg-gradient-to-br from-red-400 to-red-600 p-6 no-underline transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:from-red-500 dark:to-red-800 dark:focus-visible:outline-neutral-200 hover:opacity-90">
              <%= image_tag "avatar.webp", class: "size-9 mb-4", alt: "Rails Blocks Logo" %>
              <div class="mb-2 text-lg font-medium text-white">Rails Blocks</div>
              <p class="m-0 text-sm text-neutral-100">Beautiful, accessible components for Rails.</p>
            </a>

            <!-- Menu Items -->
            <a href="/docs/modal" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Modal</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Dialog boxes for user interactions and confirmations.</p>
            </a>

            <a href="/docs/dropdown" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Dropdown</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Contextual menus with keyboard navigation.</p>
            </a>

            <a href="/docs/tooltip" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Tooltip</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Helpful hints that appear on hover or focus.</p>
            </a>
          </div>
        </div>

        <!-- Resources Content -->
        <div id="resources-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="grid grid-cols-1 gap-2 p-2 w-[calc(100vw-2rem)] sm:w-[600px] sm:grid-cols-2">
            <a href="/docs/installation" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Installation</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Get started with Rails Blocks in your Rails app.</p>
            </a>

            <a href="/docs/authors-note" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Author's Note</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Learn about the philosophy behind Rails Blocks.</p>
            </a>

            <a href="https://railsblocks.featurebase.app/roadmap" target="_blank" rel="noopener noreferrer" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Roadmap</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">See what's coming next and vote on features.</p>
            </a>

            <a href="/changelog" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Changelog</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Latest updates and new component releases.</p>
            </a>

            <a href="/pro" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Rails Blocks Pro</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Unlock premium components and features.</p>
            </a>

            <a href="/signup" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Create an account</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">And get notified when new components are released.</p>
            </a>
          </div>
        </div>

        <!-- Mobile Menu Content -->
        <div id="mobile-menu-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="grid grid-cols-1 gap-2 p-2 w-[calc(100vw-2rem)] sm:w-[600px] sm:grid-cols-2">
            <div class="relative">
              <button type="button" data-navbar-target="trigger" data-content-id="components-content" data-action="click->navbar#toggleMenu keydown->navbar#handleTriggerKeydown" data-state="closed" class="w-full group flex items-center justify-between gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
                Components
                <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
                </svg>
              </button>
            </div>

            <!-- Resources Dropdown -->
            <div class="relative">
              <button type="button" data-navbar-target="trigger" data-content-id="resources-content" data-action="click->navbar#toggleMenu keydown->navbar#handleTriggerKeydown" data-state="closed" class="w-full group flex items-center justify-between gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
                Resources
                <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
                </svg>
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</nav>

A comprehensive navbar combining dropdown menus with a profile dropdown menu for user account actions.

<nav class="flex w-full justify-center" data-controller="navbar">
  <div class="relative">
    <!-- Menu List -->
    <ul data-navbar-target="menu" class="m-0 flex list-none items-center gap-1 rounded-lg border border-neutral-200 bg-white p-1 shadow dark:border-neutral-700 dark:bg-neutral-800">

      <li>
        <a href="/" class="block rounded-md px-3 py-2 text-sm font-medium text-neutral-700 no-underline transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Home
        </a>
      </li>

      <!-- Components Dropdown -->
      <li class="hidden sm:block">
        <button type="button" data-navbar-target="trigger" data-content-id="components-content" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Components
          <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
          </svg>
        </button>
      </li>

      <!-- Resources Dropdown -->
      <li class="hidden sm:block">
        <button type="button" data-navbar-target="trigger" data-content-id="resources-content" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Resources
          <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
          </svg>
        </button>
      </li>

      <li class="hidden sm:block">
        <a href="/changelog" class="block rounded-md px-3 py-2 text-sm font-medium text-neutral-700 no-underline transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          Changelog
        </a>
      </li>

      <!-- Mobile Menu Button -->
      <li class="relative sm:hidden">
        <button type="button" data-navbar-target="trigger" data-align="end" data-content-id="mobile-menu-content" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-1 rounded-md px-2 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="2.25" y1="9" x2="15.75" y2="9"></line><line x1="2.25" y1="3.75" x2="15.75" y2="3.75"></line><line x1="2.25" y1="14.25" x2="15.75" y2="14.25"></line></g></svg>
        </button>
      </li>

      <!-- Profile Dropdown -->
      <li class="ml-2 pl-2 border-l border-neutral-200 dark:border-neutral-700">
        <button type="button" data-navbar-target="trigger" data-content-id="profile-content" data-align="end" data-action="click->navbar#toggleMenu mouseenter->navbar#handleMouseEnter mouseleave->navbar#handleMouseLeave keydown->navbar#handleTriggerKeydown" data-state="closed" class="group flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
          <div class="flex items-center justify-center size-6 rounded-full bg-gradient-to-br from-red-400 to-red-600 text-white text-xs font-semibold">
            JD
          </div>
        </button>
      </li>
    </ul>

    <!-- Viewport Container -->
    <div data-navbar-target="viewport" data-action="mouseenter->navbar#cancelClose mouseleave->navbar#handleMouseLeave" data-state="closed" class="absolute top-full z-50 mt-2 origin-top transition-all duration-200 ease-out data-[state=closed]:pointer-events-none data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=closing]:pointer-events-none data-[state=closing]:scale-95 data-[state=closing]:opacity-0 data-[state=open]:pointer-events-auto data-[state=open]:scale-100 data-[state=open]:opacity-100 left-1/2 -translate-x-1/2 sm:left-0 sm:translate-x-0">
      <!-- Indicator Arrow -->
      <div data-navbar-target="indicator" class="pointer-events-none absolute -top-1 z-10 flex h-2 w-10 items-end justify-center overflow-visible transition-opacity duration-200">
        <div class="h-2 w-2 rotate-45 border-t border-l border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"></div>
      </div>

      <div data-navbar-target="background" class="relative z-0 rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-800">
        <!-- Components Content -->
        <div id="components-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="grid grid-cols-1 gap-2 p-5 w-[calc(100vw-2rem)] sm:w-[500px] sm:grid-cols-[0.75fr_1fr]">
            <!-- Featured Callout -->
            <a href="/" class="row-span-3 flex h-full w-full cursor-pointer flex-col justify-end rounded-lg bg-gradient-to-br from-red-400 to-red-600 p-6 no-underline transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:from-red-500 dark:to-red-800 dark:focus-visible:outline-neutral-200 hover:opacity-90">
              <%= image_tag "avatar.webp", class: "size-9 mb-4", alt: "Rails Blocks Logo" %>
              <div class="mb-2 text-lg font-medium text-white">Rails Blocks</div>
              <p class="m-0 text-sm text-neutral-100">Beautiful, accessible components for Rails.</p>
            </a>

            <!-- Menu Items -->
            <a href="/docs/modal" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Modal</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Dialog boxes for user interactions and confirmations.</p>
            </a>

            <a href="/docs/dropdown" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Dropdown</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Contextual menus with keyboard navigation.</p>
            </a>

            <a href="/docs/tooltip" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Tooltip</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Helpful hints that appear on hover or focus.</p>
            </a>
          </div>
        </div>

        <!-- Resources Content -->
        <div id="resources-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="grid grid-cols-1 gap-2 p-2 w-[calc(100vw-2rem)] sm:w-[600px] sm:grid-cols-2">
            <a href="/docs/installation" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Installation</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Get started with Rails Blocks in your Rails app.</p>
            </a>

            <a href="/docs/authors-note" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Author's Note</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Learn about the philosophy behind Rails Blocks.</p>
            </a>

            <a href="https://railsblocks.featurebase.app/roadmap" target="_blank" rel="noopener noreferrer" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Roadmap</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">See what's coming next and vote on features.</p>
            </a>

            <a href="/changelog" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Changelog</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Latest updates and new component releases.</p>
            </a>

            <a href="/pro" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Rails Blocks Pro</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">Unlock premium components and features.</p>
            </a>

            <a href="/signup" class="block cursor-pointer rounded-lg p-3 no-underline transition-colors hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <div class="mb-1 font-medium text-neutral-900 dark:text-neutral-100">Create an account</div>
              <p class="m-0 text-sm leading-snug text-neutral-600 dark:text-neutral-400">And get notified when new components are released.</p>
            </a>
          </div>
        </div>

        <!-- Profile Content -->
        <div id="profile-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="flex flex-col p-2 w-[calc(100vw-2rem)] sm:w-[200px]">
            <a href="/" class="flex items-center gap-2 rounded-lg p-2 no-underline transition-colors duration-50 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M14.855 5.95L9.605 1.96C9.247 1.688 8.752 1.688 8.395 1.96L3.145 5.95C2.896 6.139 2.75 6.434 2.75 6.747V14.251C2.75 15.356 3.645 16.251 4.75 16.251H7.25V12.251C7.25 11.699 7.698 11.251 8.25 11.251H9.75C10.302 11.251 10.75 11.699 10.75 12.251V16.251H13.25C14.355 16.251 15.25 15.356 15.25 14.251V6.746C15.25 6.433 15.104 6.14 14.855 5.95Z"></path></g></svg></span>
              <span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Home</span>
            </a>

            <a href="/account" class="flex items-center gap-2 rounded-lg p-2 no-underline transition-colors duration-50 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><circle cx="9" cy="4.5" r="2.75"></circle><path d="M13.762,15.516c.86-.271,1.312-1.221,.947-2.045-.97-2.191-3.159-3.721-5.709-3.721s-4.739,1.53-5.709,3.721c-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734s3.537-.348,4.762-.734Z"></path></g></svg></span>
              <span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Profile</span>
            </a>

            <a href="/dashboard" class="flex items-center gap-2 rounded-lg p-2 no-underline transition-colors duration-50 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M3.145 5.95003L8.395 1.96004C8.753 1.68804 9.248 1.68804 9.605 1.96004L14.855 5.95003C15.104 6.13903 15.25 6.43399 15.25 6.74599V14.25C15.25 15.355 14.355 16.25 13.25 16.25H4.75C3.645 16.25 2.75 15.355 2.75 14.25V6.74599C2.75 6.43299 2.896 6.13903 3.145 5.95003Z"></path> <path d="M9.5 12H8.5C8.2239 12 8 12.224 8 12.5V13.5C8 13.776 8.2239 14 8.5 14H9.5C9.7761 14 10 13.776 10 13.5V12.5C10 12.224 9.7761 12 9.5 12Z" fill="currentColor" data-stroke="none" stroke="none"></path> <path d="M6.5 12H5.5C5.2239 12 5 12.224 5 12.5V13.5C5 13.776 5.2239 14 5.5 14H6.5C6.7761 14 7 13.776 7 13.5V12.5C7 12.224 6.7761 12 6.5 12Z" fill="currentColor" data-stroke="none" stroke="none"></path> <path d="M12.5 12H11.5C11.2239 12 11 12.224 11 12.5V13.5C11 13.776 11.2239 14 11.5 14H12.5C12.7761 14 13 13.776 13 13.5V12.5C13 12.224 12.7761 12 12.5 12Z" fill="currentColor" data-stroke="none" stroke="none"></path> <path d="M9.5 9H8.5C8.2239 9 8 9.224 8 9.5V10.5C8 10.776 8.2239 11 8.5 11H9.5C9.7761 11 10 10.776 10 10.5V9.5C10 9.224 9.7761 9 9.5 9Z" fill="currentColor" data-stroke="none" stroke="none"></path> <path d="M6.5 9H5.5C5.2239 9 5 9.224 5 9.5V10.5C5 10.776 5.2239 11 5.5 11H6.5C6.7761 11 7 10.776 7 10.5V9.5C7 9.224 6.7761 9 6.5 9Z" fill="currentColor" data-stroke="none" stroke="none"></path> <path d="M12.5 9H11.5C11.2239 9 11 9.224 11 9.5V10.5C11 10.776 11.2239 11 11.5 11H12.5C12.7761 11 13 10.776 13 10.5V9.5C13 9.224 12.7761 9 12.5 9Z" fill="currentColor" data-stroke="none" stroke="none"></path></g></svg></span>
              <span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Dashboard</span>
            </a>

            <div class="my-1 border-t border-neutral-200 dark:border-neutral-700"></div>

            <a href="/logout" data-turbo-method="delete" class="flex items-center gap-2 rounded-lg p-2 no-underline transition-colors duration-50 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-neutral-700 dark:focus-visible:outline-neutral-200">
              <span class="text-xs text-neutral-500 dark:text-neutral-400"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M9.75,2.75h3.5c1.105,0,2,.895,2,2V13.25c0,1.105-.895,2-2,2h-3.5"></path><polyline points="6.25 5.5 2.75 9 6.25 12.5"></polyline><line x1="2.75" y1="9" x2="10.25" y2="9"></line></g></svg></span>
              <span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Sign out</span>
            </a>
          </div>
        </div>

        <!-- Mobile Menu Content -->
        <div id="mobile-menu-content" data-navbar-target="content" data-state="closed" class="hidden">
          <div class="flex flex-col gap-2 p-2 w-[calc(100vw-2rem)]">
            <div class="relative">
              <button type="button" data-navbar-target="trigger" data-content-id="components-content" data-action="click->navbar#toggleMenu keydown->navbar#handleTriggerKeydown" data-state="closed" class="w-full group flex items-center justify-between gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
                Components
                <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
                </svg>
              </button>
            </div>

            <div class="relative">
              <button type="button" data-navbar-target="trigger" data-content-id="resources-content" data-action="click->navbar#toggleMenu keydown->navbar#handleTriggerKeydown" data-state="closed" class="w-full group flex items-center justify-between gap-1 rounded-md px-3 py-2 text-sm font-medium text-neutral-700 transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
                Resources
                <svg class="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
                </svg>
              </button>
            </div>

            <a href="/changelog" class="block rounded-md px-3 py-2 text-sm font-medium text-neutral-700 no-underline transition-colors select-none hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200">
              Changelog
            </a>
          </div>
        </div>
      </div>
    </div>
  </div>
</nav>

Configuration

The navbar component is powered by a Stimulus controller that provides smooth dropdown animations, keyboard navigation, and accessibility features.

Values

Value Description Default
activeIndex
Tracks the currently active menu item index for keyboard navigation. Automatically managed by the controller. -1

Targets

Target Description Required
trigger
The clickable button element that toggles dropdown menus. Must have data-content-id attribute matching a content element. Required
content
The dropdown content container that shows/hides. Must have an id attribute matching a trigger's data-content-id. Required
viewport
The container that holds all dropdown content panels with animations and positioning. Required
indicator
The arrow indicator that points to the active trigger button. Required
menu
The main navigation menu container (typically a <ul> element). Optional

Actions

Action Description Usage
toggleMenu
Toggles the dropdown menu open/closed when clicked data-action="click->navbar#toggleMenu"
handleMouseEnter
Opens dropdown when hovering over trigger (desktop behavior) data-action="mouseenter->navbar#handleMouseEnter"
handleMouseLeave
Closes dropdown after a short delay when mouse leaves trigger data-action="mouseleave->navbar#handleMouseLeave"
cancelClose
Cancels the close timer when mouse enters the dropdown viewport data-action="mouseenter->navbar#cancelClose"
handleTriggerKeydown
Handles keyboard navigation for trigger buttons (Enter, Space, Arrow keys) data-action="keydown->navbar#handleTriggerKeydown"

Data Attributes

Attribute Description Example
data-content-id
Links a trigger button to its corresponding content panel. The value must match the id of a content element. data-content-id="components-content"
data-state
Tracks the open/closed/closing state of triggers, content, viewport, and indicator. Automatically managed by the controller. data-state="open"
data-align
Controls dropdown positioning relative to the trigger. Values: 'start' (left-aligned), 'center' (centered, default), 'end' (right-aligned). Applied to individual trigger buttons. data-align="end"

Accessibility Features

  • Keyboard Navigation: Use Enter or Space to open dropdowns, to navigate between items, Tab to move through focusable elements
  • Escape Key: Press Esc to close any open dropdown and return focus to the trigger button
  • Click Outside: Automatically closes dropdowns when clicking outside the navbar component
  • Focus Management: Proper focus handling when opening/closing dropdowns and navigating with keyboard
  • ARIA Support: Automatic data-state attribute management for CSS-based state styling

Advanced Features

  • Hover Detection: Desktop users can hover over menu items to open dropdowns with a smooth delay
  • Smart Positioning: Arrow indicator automatically positions itself to point at the active trigger
  • Smooth Animations: Coordinated animations between arrow indicator, viewport scaling, and content visibility
  • Mobile Menu Support: Nested mobile menu trigger buttons automatically position the indicator correctly
  • Close Delay: 150ms delay when mouse leaves to prevent accidental closures when moving to dropdown content
  • Multiple Dropdowns: Supports multiple dropdown menus that automatically close when another opens
  • Responsive Design: Built-in support for mobile hamburger menus and desktop navigation patterns

Table of contents

Get notified when new components come out