Keyboard shortcuts with Stimulus and Mousetrap

Jordan Lee Jan 2026
2 tabs
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts
    Mousetrap.bind('/', () => this.focusSearch())
    Mousetrap.bind('c', () => this.compose())
    Mousetrap.bind('?', () => this.showHelp())
    Mousetrap.bind('g h', () => this.goHome())
    Mousetrap.bind('g p', () => this.goPosts())

    // Vim-style navigation
    Mousetrap.bind('j', () => this.nextItem())
    Mousetrap.bind('k', () => this.previousItem())
  }

  disconnect() {
    Mousetrap.reset()
  }

  focusSearch() {
    const searchInput = document.querySelector('[data-search-input]')
    if (searchInput) {
      searchInput.focus()
      searchInput.select()
      return false // Prevent default
    }
  }

  compose() {
    const composeButton = document.querySelector('[data-compose-button]')
    if (composeButton) {
      composeButton.click()
      return false
    }
  }

  showHelp() {
    const helpButton = document.querySelector('[data-help-button]')
    if (helpButton) {
      helpButton.click()
      return false
    }
  }

  goHome() {
    window.location.href = '/'
    return false
  }

  goPosts() {
    window.location.href = '/posts'
    return false
  }

  nextItem() {
    const items = document.querySelectorAll('[data-item]')
    const current = document.activeElement.closest('[data-item]')

    if (!current && items.length > 0) {
      items[0].focus()
    } else {
      const currentIndex = Array.from(items).indexOf(current)
      const next = items[currentIndex + 1]
      if (next) next.focus()
    }
    return false
  }

  previousItem() {
    const items = document.querySelectorAll('[data-item]')
    const current = document.activeElement.closest('[data-item]')

    if (current) {
      const currentIndex = Array.from(items).indexOf(current)
      const prev = items[currentIndex - 1]
      if (prev) prev.focus()
    }
    return false
  }
}
2 files · javascript, erb Explain with highlit

Power users appreciate keyboard shortcuts that speed up common actions. I integrate the Mousetrap library via Stimulus to define app-wide shortcuts like cmd+k for search, c to compose, or ? to show help. The controller binds shortcuts on connect and unbinds on disconnect to avoid memory leaks. I'm careful about context—some shortcuts only work on certain pages, and I disable them when typing in inputs. A help modal triggered by ? documents available shortcuts. This pattern significantly improves productivity for frequent users while remaining invisible to those who don't use shortcuts. I also show keyboard hints in tooltips as progressive disclosure.