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.
Related snips
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
rails
hotwire
stimulus
by Henry Kim
2 tabs
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
stimulus
javascript
ux
by Jordan Lee
2 tabs
javascript
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
rails
hotwire
stimulus
by codesnips
4 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
html
forms
validation
by Alex Chang
1 tab
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
rails
hotwire
stimulus
by codesnips
3 tabs
javascript
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "message", "confirm", "cancel"]
static values = {
message: String
Turbo confirmation dialogs with custom modal
hotwire
turbo
stimulus
by Jordan Lee
3 tabs
Share this code
Here's the card — post it anywhere.