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.openDelayTimeout = null;
this.skipDelayTimeout = null;
this.justOpened = false;
this.isAnimating = false; // Track if an animation is in progress
this.isOpenDelayed = true; // Whether to apply delay when opening
this.pendingTransition = null; // Queue pending transition while animating
this.hasPointerMoveOpened = new Map(); // Track which triggers have opened via pointer movement
this.targetDimensions = null; // Track the target dimensions we're animating to
// Delay durations
this.delayDuration = 200; // Initial open delay
this.skipDelayDuration = 300; // Time window to skip delay after closing
// 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);
}
disconnect() {
document.removeEventListener("click", this.handleClickOutside);
document.removeEventListener("keydown", this.handleKeydown);
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
if (this.closeAnimationTimeout) {
clearTimeout(this.closeAnimationTimeout);
}
if (this.transitionTimeout) {
clearTimeout(this.transitionTimeout);
}
if (this.openDelayTimeout) {
clearTimeout(this.openDelayTimeout);
}
if (this.skipDelayTimeout) {
clearTimeout(this.skipDelayTimeout);
}
}
toggleMenu(event) {
const trigger = event.currentTarget;
const contentId = trigger.dataset.contentId;
// Clear any pending open delay - clicks should be instant
if (this.openDelayTimeout) {
clearTimeout(this.openDelayTimeout);
this.openDelayTimeout = null;
}
// 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 (clicks are always instant, no delay)
this.openMenu(trigger, contentId);
}
handlePointerEnter(event) {
// Skip hover behavior on touch devices to prevent conflicts with tap/click
if (this.isTouchDevice) {
return;
}
// Only handle mouse pointers, not touch or pen
if (event.pointerType && event.pointerType !== "mouse") {
return;
}
const trigger = event.currentTarget;
const contentId = trigger.dataset.contentId;
// Reset the pointer move flag when entering a new trigger
this.hasPointerMoveOpened.set(contentId, false);
// Clear any pending open delay
if (this.openDelayTimeout) {
clearTimeout(this.openDelayTimeout);
this.openDelayTimeout = null;
}
// 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;
}
}
handlePointerMove(event) {
// Skip hover behavior on touch devices to prevent conflicts with tap/click
if (this.isTouchDevice) {
return;
}
// Only respond to mouse pointers, not touch or pen
if (event.pointerType && event.pointerType !== "mouse") {
return;
}
const trigger = event.currentTarget;
const contentId = trigger.dataset.contentId;
// If we've already opened via pointer movement for this trigger, don't do it again
if (this.hasPointerMoveOpened.get(contentId)) {
return;
}
// Mark that we've opened via pointer movement
this.hasPointerMoveOpened.set(contentId, true);
// If a different menu is open, smoothly transition to the new one (no delay)
if (this.isOpen && this.currentContentId !== contentId) {
// Don't transition if still animating - queue it for when animation completes
if (this.isAnimating) {
this.pendingTransition = { trigger, contentId };
return;
}
this.transitionToMenu(trigger, contentId);
return;
}
// Opening from closed state - apply delay if needed
if (!this.isOpen) {
if (this.isOpenDelayed) {
// Apply delay before opening
this.openDelayTimeout = setTimeout(() => {
this.openMenu(trigger, contentId);
this.openDelayTimeout = null;
}, this.delayDuration);
} else {
// No delay - open immediately (within skip delay window)
this.openMenu(trigger, contentId);
}
}
}
handlePointerLeave(event) {
// Skip hover behavior on touch devices to prevent conflicts with tap/click
if (this.isTouchDevice) {
return;
}
// Only handle mouse pointers, not touch or pen
if (event.pointerType && event.pointerType !== "mouse") {
return;
}
const trigger = event.currentTarget;
const contentId = trigger.dataset.contentId;
// Reset the pointer move flag when leaving the trigger
this.hasPointerMoveOpened.set(contentId, false);
// Clear any pending open delay - user moved away before it opened
if (this.openDelayTimeout) {
clearTimeout(this.openDelayTimeout);
this.openDelayTimeout = null;
}
// Clear any existing close timeout
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
// Close after a delay
const delay = this.justOpened ? 350 : 150;
this.closeTimeout = setTimeout(() => {
this.closeMenu();
}, delay);
}
cancelClose(event) {
// Skip hover behavior on touch devices to prevent conflicts with tap/click
if (this.isTouchDevice) {
return;
}
// Only handle mouse pointers, not touch or pen
if (event.pointerType && event.pointerType !== "mouse") {
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;
}
// Clear skip delay timer since we're opening
if (this.skipDelayTimeout) {
clearTimeout(this.skipDelayTimeout);
this.skipDelayTimeout = null;
}
this.isOpen = true;
this.isAnimating = true; // Mark that we're animating
this.isOpenDelayed = false; // No delay for subsequent opens (within skip delay window)
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";
// Store dimensions for potential transitions
requestAnimationFrame(() => {
if (content && !content.classList.contains("hidden")) {
const contentRect = content.getBoundingClientRect();
this.targetDimensions = { width: contentRect.width, height: contentRect.height };
}
});
// 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";
// Clear animation flag after the viewport animation completes (200ms)
setTimeout(() => {
this.isAnimating = false;
this.executePendingTransition();
}, 200);
});
});
} else if (this.hasViewportTarget) {
// If no indicator, just show viewport
this.viewportTarget.dataset.state = "open";
// Clear animation flag after a short delay
setTimeout(() => {
this.isAnimating = false;
this.executePendingTransition();
}, 200);
}
}
executePendingTransition() {
if (this.pendingTransition) {
const { trigger, contentId } = this.pendingTransition;
this.pendingTransition = null;
// The normal hover-out logic will handle closing if the user has moved away
this.transitionToMenu(trigger, contentId);
}
}
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;
// Mark that we're animating
this.isAnimating = true;
// 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;
// Note: Don't clear isAnimating here - we're starting a new animation immediately
// 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
// Disable transitions completely so CSS transition-all doesn't interfere
this.viewportTarget.style.transition = "none";
if (this.hasBackgroundTarget) {
this.backgroundTarget.style.overflow = "";
this.backgroundTarget.style.transition = "none";
}
if (this.hasIndicatorTarget) {
this.indicatorTarget.style.transition = "none";
}
// 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(() => {
// IMPORTANT: Use stored target dimensions from previous transition if available
// This ensures we start from the correct dimensions even if we're mid-transition
// Use getBoundingClientRect for subpixel precision to avoid stuttering
let currentWidth, currentHeight;
if (this.targetDimensions) {
// Use the stored target dimensions from the previous transition
// This is more reliable than reading mid-transition values from the DOM
currentWidth = this.targetDimensions.width;
currentHeight = this.targetDimensions.height;
} else {
// No stored dimensions - measure from current content
// This happens on the first transition or after menu was fully closed
const currentContent = oldContent && !oldContent.classList.contains("hidden") ? oldContent : null;
if (currentContent) {
const currentContentRect = currentContent.getBoundingClientRect();
currentWidth = currentContentRect.width;
currentHeight = currentContentRect.height;
} else {
// Fallback to viewport size
const viewportRect = this.viewportTarget.getBoundingClientRect();
currentWidth = viewportRect.width;
currentHeight = viewportRect.height;
}
}
// 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
// Use getBoundingClientRect for subpixel precision to avoid stuttering
const newContentRect = newContent.getBoundingClientRect();
const newWidth = newContentRect.width;
const newHeight = newContentRect.height;
// Store the target dimensions for the next potential transition
this.targetDimensions = { width: newWidth, height: newHeight };
// 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
// Small buffer to ensure all transitions complete before cleanup
this.transitionTimeout = setTimeout(() => {
// Clear animation flag
this.isAnimating = false;
// Execute any pending transition
this.executePendingTransition();
// Disable transitions completely to prevent CSS transition-all from causing stutter
this.viewportTarget.style.transition = "none";
if (this.hasBackgroundTarget) {
this.backgroundTarget.style.overflow = "";
this.backgroundTarget.style.transition = "none";
}
if (this.hasIndicatorTarget) {
this.indicatorTarget.style.transition = "none";
}
// Reset new content positioning
newContent.style.position = "";
newContent.style.width = "";
newContent.style.top = "";
newContent.style.left = "";
newContent.style.opacity = "";
newContent.style.transition = "none";
// 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 = "none";
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;
this.isAnimating = false; // Clear animation flag when closing
this.pendingTransition = null; // Clear any pending transitions
this.targetDimensions = null; // Clear stored dimensions
// Start skip delay timer - if user reopens within this window, no delay applied
if (this.skipDelayTimeout) {
clearTimeout(this.skipDelayTimeout);
}
this.skipDelayTimeout = setTimeout(() => {
this.isOpenDelayed = true;
}, this.skipDelayDuration);
// Hide viewport with animation
if (this.hasViewportTarget) {
if (animate) {
// Clear inline transition to allow CSS transition-all to handle the close animation
this.viewportTarget.style.transition = "";
// Also clear content transitions
if (content) {
content.style.transition = "";
}
// 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;
// Clear any pending open delay - keyboard should be instant
if (this.openDelayTimeout) {
clearTimeout(this.openDelayTimeout);
this.openDelayTimeout = null;
}
// 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 (keyboard is always instant, no delay)
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 (keyboard is always instant, no delay)
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-fit 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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="pointerenter->navbar#cancelClose pointerleave->navbar#handlePointerLeave" 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 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 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 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 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 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 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 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 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 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 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 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>
Navbar with Profile Dropdown
A comprehensive navbar combining dropdown menus with a profile dropdown menu for user account actions.
<nav class="flex w-fit 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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 pointerenter->navbar#handlePointerEnter pointermove->navbar#handlePointerMove pointerleave->navbar#handlePointerLeave 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 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="pointerenter->navbar#cancelClose pointerleave->navbar#handlePointerLeave" 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 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 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 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 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 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 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 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 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 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 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 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
Targets
Actions
Data Attributes
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-stateattribute 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