keybuddy
Keyboard shortcuts for the browser, built around physical keys, chords, sequences, and scopes.
Install
pnpm add keybuddy
Usage
import { createKeybuddy } from 'keybuddy';
const kb = createKeybuddy(document);
kb.bind('cmd+s', save);
kb.bind('a+b', toggleOverlay);
kb.bind('g i', goToInbox);
kb.bind('cmd+k cmd+c', copyCode);
| Binding | Action |
|---|---|
cmd+s |
hold Cmd and press S |
a+b |
hold A and B together |
g i |
press G, then I |
cmd+k cmd+c |
press Cmd+K, then Cmd+C |
ctrl k |
tap Ctrl, release it, then press K |
The first step in a sequence acts as its leader key. ctrl k and ctrl+k are different:
the former is a sequence, the latter is a chord.
Physical keys
Keybuddy uses KeyboardEvent.code, so bindings follow key positions, not printed characters.
a means the position of A on a QWERTY keyboard even when another layout prints something else
on that key. If the typed character matters, use input or beforeinput instead.
Scopes
A new instance starts in DEFAULT_SCOPE, whose value is 'default'. Exactly one scope is active
at a time; default is an ordinary scope, not a global layer.
The same shortcut can do different things in different parts of an application:
import { createKeybuddy, DEFAULT_SCOPE } from 'keybuddy';
const kb = createKeybuddy(document);
kb.bind('escape', closeHome, { scope: DEFAULT_SCOPE });
kb.bind('escape', closeEditor, { scope: 'editor' });
kb.bind('escape', closePalette, { scope: 'palette' });
kb.setScope('editor');
Only closeEditor can run while editor is active. Bindings from default do not also run, and
bindings in different scopes do not conflict. Within one scope, Keybuddy rejects an ambiguous
pair such as standalone g and sequence g i.
When scope is omitted, bind, unbind, unbindKeys, isBound, and canBind use the active
scope.
Instances
Each instance owns its bindings, listeners, scope, and held-key state. There is no global singleton, so separate documents and iframes can use separate instances.
const mainKeys = createKeybuddy(document);
const iframeKeys = createKeybuddy(iframe.contentDocument as Document);
The default filter ignores shortcuts from input, select, textarea, and contenteditable
elements. Replace it when the application needs different behavior:
const kb = createKeybuddy(document, {
filter: (event) => shouldHandleShortcut(event),
scope: 'editor',
sequenceTimeout: 1500,
});
Bindings
Multiple handlers can share a binding. Set exclusive: true when one handler should win over
other bindings matched by the same event:
kb.bind('escape', closeModal, { exclusive: true });
Remove one handler, every handler for a shortcut, or a whole scope:
kb.unbind('ctrl+s', save);
kb.unbindKeys('ctrl+s');
kb.unbindScope('editor');
The unbind methods return the number of removed handlers.
For configurable shortcuts, canBind reports invalid input and conflicts without throwing:
const result = kb.canBind('g i');
if (!result.ok) {
showShortcutError(result.message);
}
getBindings returns registered bindings with their scope, handler, exclusivity, and parsed
steps. It can be used to build a shortcut settings screen or cheat sheet.
kb.getBindings();
kb.getBindings({ scope: 'editor' });
kb.getBindings({ keys: 'ctrl+s' });
API
createKeybuddy(document, options?)
| Option | Default | Purpose |
|---|---|---|
filter |
ignores editable targets | decides whether an event should be handled |
scope |
default |
sets the initial scope |
sequenceTimeout |
1000 |
sets the sequence timeout in milliseconds |
Instance methods
| Method | Purpose |
|---|---|
bind(keys, handler, options?) |
register a handler |
unbind(keys, handler, options?) |
remove an exact handler |
unbindKeys(keys, options?) |
remove every handler for a binding |
unbindScope(scope) |
remove every handler in a scope |
unbindAll() |
remove every handler |
isBound(keys, options?) |
check whether a binding exists |
canBind(keys, options?) |
validate a binding and report conflicts |
getBindings(options?) |
inspect registered bindings |
setScope(scope) |
change the active scope |
getScope() |
read the active scope |
reset() |
clear held keys and pending sequences |
destroy() |
remove all bindings and event listeners |
Invalid or conflicting bindings passed to bind throw BindError.
Supported keys
| Group | Keys |
|---|---|
| Letters and digits | a–z, 0–9 |
| Modifiers | shift, alt/option, control/ctrl, meta/cmd/command |
| Function | f1–f24 |
| Navigation | arrows, home, end, pageup, pagedown |
| Editing | enter, escape, backspace, tab, space, delete, insert |
| Locks | capslock, numlock, scrolllock |
| Other | punctuation, numpad, system, and international keys |
Tokens are case-insensitive. ⇧, ⌥, ⌃, and ⌘ are modifier aliases.
Not supported
- layout-aware character bindings — Keybuddy always uses physical key positions;
- comma-separated alternatives such as
cmd+r, ctrl+r— register each binding separately; - media and browser keys outside the groups above;
- unknown key names —
bindthrowsBindErrorinstead of registering them.