Mobile-first responsive navigation with Stimulus
Jordan Lee
Jan 2026
2 tabs
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "overlay", "toggle"]
toggle() {
const isOpen = this.menuTarget.classList.contains('open')
if (isOpen) {
this.close()
} else {
this.open()
}
}
open() {
this.menuTarget.classList.add('open')
this.overlayTarget.classList.remove('hidden')
this.toggleTarget.setAttribute('aria-expanded', 'true')
// Prevent body scroll
document.body.style.overflow = 'hidden'
// Focus first link for keyboard navigation
const firstLink = this.menuTarget.querySelector('a')
if (firstLink) firstLink.focus()
}
close() {
this.menuTarget.classList.remove('open')
this.overlayTarget.classList.add('hidden')
this.toggleTarget.setAttribute('aria-expanded', 'false')
// Restore body scroll
document.body.style.overflow = ''
}
closeOnEscape(event) {
if (event.key === 'Escape') {
this.close()
}
}
closeOnResize() {
if (window.innerWidth >= 768) {
this.close()
}
}
}
<nav class="navbar"
data-controller="mobile-nav"
data-action="resize@window->mobile-nav#closeOnResize keydown@window->mobile-nav#closeOnEscape">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<%= link_to "MyApp", root_path, class: "text-xl font-bold" %>
<!-- Mobile menu button -->
<button type="button"
class="md:hidden"
data-mobile-nav-target="toggle"
data-action="mobile-nav#toggle"
aria-expanded="false"
aria-label="Toggle navigation">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<!-- Desktop menu -->
<div class="hidden md:flex items-center gap-6">
<%= link_to "Posts", posts_path, class: "nav-link" %>
<%= link_to "About", about_path, class: "nav-link" %>
<% if current_user %>
<%= link_to "Profile", profile_path, class: "nav-link" %>
<%= button_to "Sign Out", destroy_user_session_path, method: :delete, class: "btn btn-sm" %>
<% else %>
<%= link_to "Sign In", new_user_session_path, class: "btn btn-sm" %>
<% end %>
</div>
</div>
</div>
<!-- Mobile menu overlay -->
<div class="fixed inset-0 bg-black bg-opacity-50 z-40 hidden"
data-mobile-nav-target="overlay"
data-action="click->mobile-nav#close"></div>
<!-- Mobile menu drawer -->
<div class="mobile-menu fixed top-0 right-0 bottom-0 w-64 bg-white shadow-xl z-50 transform translate-x-full transition-transform"
data-mobile-nav-target="menu">
<div class="p-6">
<button type="button"
class="mb-6"
data-action="mobile-nav#close"
aria-label="Close menu">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="flex flex-col gap-4">
<%= link_to "Posts", posts_path, class: "nav-link", data: { action: "mobile-nav#close" } %>
<%= link_to "About", about_path, class: "nav-link", data: { action: "mobile-nav#close" } %>
<% if current_user %>
<%= link_to "Profile", profile_path, class: "nav-link", data: { action: "mobile-nav#close" } %>
<%= button_to "Sign Out", destroy_user_session_path, method: :delete, class: "btn btn-primary w-full" %>
<% else %>
<%= link_to "Sign In", new_user_session_path, class: "btn btn-primary w-full" %>
<% end %>
</div>
</div>
</div>
</nav>
<style>
.mobile-menu.open {
transform: translateX(0);
}
</style>
2 files · javascript, erb
Explain with highlit
Mobile navigation requires different patterns than desktop—hamburger menus, slide-out drawers, and touch-friendly interactions. I build a responsive nav with Stimulus that shows a mobile menu button below a breakpoint and auto-hides when links are clicked. The controller handles opening/closing animations, focus trapping for accessibility, and escape key handling. On wider screens, CSS shows the full horizontal nav and Stimulus gracefully does nothing. This progressive enhancement approach ensures the nav works even if JavaScript fails—the mobile menu button falls back to a traditional anchor that scrolls to the nav. Touch gestures like swipe-to-close add polish for mobile users.