Lazy Hotkeys for Lazy People

Stop writing JavaScript. Your keyboard already knows what to do.

Declarative keyboard shortcuts for Rails. Define hotkeys in your views, watch them work. No Stimulus controllers. No event listeners. No addEventListener for the 10,000th time.

Install

1. Add the gem:

bundle add lazy_hotkeys

2. Run the generator:

gem "lazy_hotkeys", "~> 0.1"

This copies lazy_hotkeys.js to app/javascript/

3. Load it:

Importmap:

pin "lazy_hotkeys", to: "lazy_hotkeys.js"

Then import it:

// app/javascript/application.js
import "lazy_hotkeys"

esbuild/rollup/webpack or Vite:

Import with relative path in your entry point:

// app/javascript/application.js  (or entrypoints/application.js in Vite)
import "./lazy_hotkeys"

Done. Hotkeys work now. (hopefully)


1. Hotkeys

Press keys. Things happen. Stop pretending you like writing JavaScript.

<%= hotkey("g i", visit: "/inbox") %>
<%= hotkey("ctrl+n", visit: new_post_path) %>

Press g then i. You're in your inbox. Magic? No. Just less suffering.

Send a Request

<%= hotkey("ctrl+s", to: "/save", method: :post) %>
<%= hotkey("ctrl+d", to: post_path(@post), method: :delete) %>

Ctrl+S sends a POST. Ctrl+D deletes. Your mouse is crying. Good.

Dispatch an Action

<%= hotkey("ctrl+k", action: "open-command-palette") %>
// Some Stimulus controller, somewhere
document.addEventListener('lazy-hotkeys:action', (e) => {
  if (e.detail.action === 'open-command-palette') {
    this.open();
  }
});

For when you absolutely must write JavaScript.

Chain Multiple Actions

<%= hotkey("ctrl+s", chain: [
  { type: "dom", target: "#status", set_text: "Saving..." },
  { type: "request", to: "/save", method: "post" },
  { type: "dom", target: "#status", set_text: "Saved!" }
]) %>

One key. Multiple actions. Sequential.


2. DOM Manipulation

Show/Hide Things

<%= hotkey("?", dom: { target: "#help", toggle_class: "hidden" }) %>
<%= hotkey("esc", dom: { target: ".modal", add_class: "hidden" }) %>
<%= hotkey("ctrl+h", dom: { target: "#sidebar", remove_class: "collapsed" }) %>

Change Text

<%= hotkey("ctrl+shift+c", dom: {
  target: "#counter",
  set_text: "9999"
}) %>

Click Things

<%= hotkey("ctrl+enter", dom: { target: "form button", click: true }) %>
<%= hotkey("/", dom: { target: "#search-input", focus: true }) %>

Your hands never leave the keyboard. Your mouse collects dust. Evolution.

Set Attributes

<%= hotkey("ctrl+d", dom: { target: "#mode", set_attr: { "data-theme": "dark" }}) %>

Use a Template (For more complex HTML)

<%= hotkey("ctrl+p", dom: { target: "#preview", replace_with: "template" }) do %>
  <div class="preview-panel">
    <h3>Preview</h3>
    <p>Your content here</p>
  </div>
<% end %>

Press the key. The template replaces the target. No innerHTML. No XSS. Just works.


Options

Option Description
visit: "/path" Navigate to URL (Turbo or regular)
to: "/endpoint" Send request (GET/POST/PATCH/DELETE)
method: :post HTTP method (with to:)
params: { ... } Request params (with to:)
action: "name" Dispatch custom event
dom: { ... } Manipulate DOM (see below)
chain: [...] Multiple actions in sequence
scope: "#form" Only works when element exists
prevent_default: false Allow browser default (default: true)
same_origin: false Allow cross-origin (default: true)
hint: "Save" Tooltip/hint text

DOM Options

Option Description Example
target: "#id" CSS selector Required
click: true Click the element { target: "button", click: true }
focus: true Focus the element { target: "input", focus: true }
set_text: "..." Change text (safe) { target: "#status", set_text: "Done" }
add_class: "..." Add CSS class { target: "#box", add_class: "active" }
remove_class: "..." Remove CSS class { target: "#box", remove_class: "hidden" }
toggle_class: "..." Toggle CSS class { target: "#menu", toggle_class: "open" }
set_attr: { k: v } Set attributes (whitelisted) { target: "#mode", set_attr: { "data-theme": "dark" } }
remove_attr: "..." Remove attribute { target: "#input", remove_attr: "disabled" }
replace_with: "template" Replace with template content See template example above

Cross-Origin

By default, requests to other domains are blocked:

<%# This works %>
<%= hotkey("ctrl+s", to: "/save") %>

<%# This is blocked %>
<%= hotkey("ctrl+x", to: "https://github.com/Plan-Vert/open-letter") %>

<%# This works (you asked for it) %>
<%= hotkey("?", visit: "https://discord.gg/BUtwjJTwxt", same_origin: false) %>

javascript:, data:, and file: URLs are always blocked, even with same_origin: false.

Attribute whitelist:

Check lazy_hotkeys.js and adjust attribute whitelist to your needs, define only what you need.

Config

Minimal config at the top of lazy_hotkeys.js:

const CFG = {
    normalizeCmdToCtrl: true, // Cmd on Mac = Ctrl on Windows
    skipInInputs: true, // Don't fire when typing in inputs
    sequenceTimeoutMs: 800 // How long to wait for sequences like "g i"
};

Change these values directly in the file. That's it.


Requirements

Rails 5.1+ (needs tag.template helper)


License

MIT