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()
    }
  }
}
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.