Small, opinionated keyboard shortcuts as a drop-in plugin. Ships with smart defaults (cmd+k search, shift+? help panel, esc close), conflict detection at register-time, runtime input guard, and an auto-rendered grouped help panel.
goboldlyforward.github.io/keycuts.js — try the defaults, register custom shortcuts, watch conflicts get caught.
Mounts a keydown listener on document, normalizes the event into a ctrl+alt+shift+meta+key combo, and runs the matching shortcut — focus an element, click it, submit a form, toggle the help panel, or run a handler you supply. Plain-key shortcuts (e, a) are skipped while someone is typing in an input. The help-panel renderer reads from the live registry and re-renders when shortcuts change.
For now, download keycuts.js and keycuts.css from this repo. (npm publication pending.)
<link rel="stylesheet" href="path/to/keycuts.css">
<script src="path/to/keycuts.js"></script><input data-keycuts-search type="search" placeholder="Search…">
<div id="shortcuts"></div>
<script>
const cuts = new Keycuts(); // ships cmd+k, shift+?, esc
cuts.add({
id: 'new_project',
keys: 'cmd+n',
description: 'New project',
group: 'Projects',
selector: '[data-command="new-project"]',
});
cuts.mountList('#shortcuts'); // toggled by shift+?
</script>new Keycuts(options?) registers the listener immediately. Pass { defaults: false } to skip the built-in shortcuts and start from an empty registry.
Three shortcuts ship enabled out of the box:
| Keys | Command | Default target |
|---|---|---|
cmd+k |
Search (focuses) | [data-keycuts-search], [data-command="search"] |
shift+? |
Show keyboard shortcuts | toggles every panel mounted with cuts.mountList(...) |
esc |
Close dialog or panel | [data-keycuts-close], [data-command="close"] |
Disable any default by id:
cuts.remove('search');
// or temporarily:
cuts.disable('search');
cuts.enable('search');Replace one in place:
cuts.replace({
id: 'search',
keys: 'cmd+/',
description: 'Search',
group: 'Navigation',
selector: '[data-command="search"]',
action: 'focus',
});A broader catalog ships in Keycuts.COMMON — cherry-pick the ones your app supports:
const cuts = new Keycuts();
cuts.add(Keycuts.COMMON.find(s => s.id === 'save'));
cuts.add(Keycuts.COMMON.find(s => s.id === 'command_palette'));| Id | Keys | Action | Default selector |
|---|---|---|---|
search |
cmd+k |
focus | [data-keycuts-search], [data-command="search"] |
help |
shift+? |
toggle | (toggles mounted help panels) |
close |
esc |
click | [data-keycuts-close], [data-command="close"] |
command_palette |
cmd+shift+p |
click | [data-keycuts-command-palette], [data-command="command-palette"] |
new_record |
cmd+n |
click | [data-command="new"] |
save |
cmd+s |
submit | [data-command="save"] |
edit |
e |
click | [data-command="edit"] |
archive |
a |
click | [data-command="archive"] |
delete |
shift+backspace |
click | [data-command="delete"] |
cuts.add({
id: 'reload',
keys: 'cmd+r',
description: 'Reload data',
handler: (event, shortcut) => {
// Anything you want. Return `false` to skip preventDefault.
refreshData();
},
});| Action | Behavior |
|---|---|
click (default) |
Looks up selector, calls .click() |
focus |
Looks up selector, calls .focus() |
submit |
Looks up selector, calls .requestSubmit() (form) or .click() (button) |
keycuts:toggle-help |
Toggles every panel mounted via mountList() |
custom handler |
Runs your function with (event, shortcut). preventDefault unless you return false |
Every action also dispatches a keycuts:invoke CustomEvent (bubbling, detail.shortcut) on the target element.
Plain keys like e or a would steal keystrokes while someone is typing. The guard skips a shortcut whenever the event target is inside an input field:
input, textarea, select, [contenteditable="true"], [role="textbox"]
Opt back in per-shortcut for combos that are always safe to fire (e.g. cmd+s to save):
cuts.add({
id: 'save',
keys: 'cmd+s',
description: 'Save',
action: 'submit',
selector: 'form[data-command="save"]',
allowInInputs: true,
});Override the selector globally on construction:
new Keycuts({
ignoreInputSelector: 'input, textarea, [data-no-shortcuts]',
});add() throws if either the id or the normalized keys is already registered. The message tells you what conflicts:
cuts.add({ id: 'search', keys: 'cmd+/', ... });
// Error: Keycuts: id search already registered (was cmd+k).
// Pass { replace: true } to overwrite intentionally.Pass { replace: true } (or use cuts.replace(...)) to overwrite intentionally. addMany() is shorthand for bulk-loading with replace semantics.
Mount on any empty element — the plugin builds grouped <section> markup inside it and re-renders whenever the registry changes:
<div id="shortcuts"></div>
<script>
const cuts = new Keycuts();
cuts.mountList('#shortcuts');
</script>shift+? toggles every mounted panel; call cuts.toggleList(), cuts.showList(), or cuts.hideList() from your own UI as well. The default styling is a centered modal with a backdrop; switch to inline rendering by adding .keycuts-shortcuts--inline to the host element.
Key labels are humanized per-platform via Keycuts.humanizeKeys(keys) — ⌘K on macOS, Ctrl+K on Windows/Linux.
new Keycuts({
defaults: true, // register cmd+k, shift+?, esc
consoleWarnings: true, // warn on conflicts (errors throw regardless)
ignoreInputSelector: 'input, textarea, select, [contenteditable="true"], [role="textbox"]',
autoBind: true, // attach the keydown listener immediately
target: document, // element to attach keydown to
shortcuts: [], // additional shortcuts to register at construction
});cuts.add(shortcut, { replace: false }); // register one
cuts.addMany([ ... ]); // register many (replace semantics)
cuts.replace(shortcut); // overwrite by id-or-keys
cuts.remove(idOrKeys); // unregister
cuts.disable(idOrKeys); // keep registered, stop firing
cuts.enable(idOrKeys); // re-enable
cuts.list(); // → Array<shortcut>
cuts.grouped(); // → { groupName: Array<shortcut> }
cuts.mountList(target, { hidden: true }); // render + register a help panel
cuts.toggleList(force?); // toggle every mounted panel
cuts.showList(); cuts.hideList();
cuts.handle(event); // manually feed a KeyboardEvent
cuts.destroy(); // tear down listener + panelsKeycuts.DEFAULTS; // the three built-ins
Keycuts.COMMON; // broader catalog (cherry-pick what your app supports)
Keycuts.normalizeKeys('Cmd+K'); // → 'meta+k'
Keycuts.humanizeKeys('meta+k'); // → '⌘K' on Mac, 'Ctrl+K' elsewherekeys strings are parsed by splitting on + and normalizing each part. Modifier aliases:
cmd,command→metaoption,opt→altcontrol,ctl→ctrlescape→escreturn→enterspacebar→space
Modifiers always emit in ctrl+alt+shift+meta+key order, so Shift+Cmd+P and meta+shift+p resolve to the same combo. Special keys: esc, enter, space, backspace, up, down, left, right.
Every successful invocation dispatches a bubbling CustomEvent on the target:
document.addEventListener('keycuts:invoke', (e) => {
console.log(e.detail.shortcut.id, 'fired');
});HTML, CSS, and ~6KB of JavaScript. No framework, no build step. Uses addEventListener and CustomEvent.
- Shortcut registry with id + keys conflict detection
- Built-in defaults: search, help, close
- Common-command catalog (cherry-pick into your app)
- Input guard with per-shortcut
allowInInputsopt-in - Built-in actions:
click,focus,submit, toggle help - Custom
handlercallback per shortcut - Auto-rendered help panel (modal + inline variants)
- Live re-render of help panel as the registry changes
- Per-platform key humanization (⌘ on Mac, Ctrl elsewhere)
-
keycuts:invokeCustomEvent for observers - Demo with playground + shortcuts list
- Publish to npm (as
@goboldlyforward/keycuts) - Deploy demo to gh-pages
- GitHub Actions CI (eslint + stylelint)
- Key-sequence support (
g then ifor Gmail-style nav) - Scoped registries (per-page or per-modal shortcut sets)
- Optional
keycuts-railsgem wrapper
MIT — see LICENSE.