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.
Navigate Somewhere
<%= 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