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>
Navbar with Profile Dropdown
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