@zakkster/lite-hotkey
v1.0.0
Published
Zero-GC keyboard router with bitmasked modifier matching. Layout-independent — uses KeyboardEvent.code.
Maintainers
Readme
@zakkster/lite-hotkey
Zero-GC keyboard router with bitmasked modifier matching.
A single Map.get on a 32-bit hash dispatches every keystroke. No string compares in the hot path, no array allocation, no per-event object churn. Layout-independent by design — combos resolve through KeyboardEvent.code, so a Cyrillic, Dvorak, or AZERTY user hits the same physical keys as a QWERTY user.
import { HotkeyRouter } from '@zakkster/lite-hotkey';
const router = new HotkeyRouter();
router.bind('Ctrl+KeyZ', () => editor.undo());
router.bind('Shift+Ctrl+KeyZ', () => editor.redo());
router.bind('Escape', () => closeModal());
router.bind('Alt+ArrowLeft', () => history.back());
window.addEventListener('keydown', router.handleEvent.bind(router));Contents
- ⚠️ The one thing you must know
- Why · Install · Quick start
- How it works
- API reference
- Edge cases & guarantees
- FAQ
- License
⚠️ The one thing you must know
Combos use KeyboardEvent.code, NOT KeyboardEvent.key.
| ✅ Bind this | ❌ Not this | Why |
|---------------------|---------------|--------------------------------------------------------------------|
| 'Ctrl+KeyZ' | 'Ctrl+Z' | e.code is 'KeyZ', never 'Z'. |
| 'Shift+Digit1' | 'Shift+1' | e.code is 'Digit1'. (Also: e.key for Shift+1 is '!' anyway.) |
| 'Alt+ArrowLeft' | 'Alt+Left' | e.code is 'ArrowLeft'. |
| 'Slash' | '/' | e.code for the / key is 'Slash'. |
| 'F1' | 'F1' | (function keys happen to match) |
| 'Escape' | 'Escape' | (named keys happen to match) |
e.code describes the physical key. It is layout-independent: the user's QWERTY Z, Cyrillic Я, and AZERTY W all sit on the same piece of plastic and all produce e.code === 'KeyZ'. Bind to that, and your shortcut works for everyone. Bind to e.key and your Cyrillic users get nothing.
The full enumeration of valid code values: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values.
The router throws if you try to bind('Ctrl') (modifiers only, no key code). It will not throw on bind('Ctrl+Z') — there's no way to know that 'Z' isn't a valid e.code value just by looking at the string. The combo simply will never fire.
Why
Most hotkey libraries do roughly this on every keystroke:
function onKey(e) {
const combo = [];
if (e.ctrlKey) combo.push('ctrl');
if (e.shiftKey) combo.push('shift');
if (e.altKey) combo.push('alt');
if (e.metaKey) combo.push('meta');
combo.push(e.key.toLowerCase());
const key = combo.join('+'); // 🔥 string allocation
if (bindings[key]) bindings[key](e); // 🔥 string hash + compare
}That's an array, a string, and a hash-table compare per keystroke. For a user typing in a code editor, that's hundreds of allocations per second.
flowchart LR
subgraph Naive["Naive: per keystroke"]
N1[allocate array] --> N2[push 4 strings]
N2 --> N3[join '+']
N3 --> N4[hash + compare]
end
subgraph Lite["lite-hotkey: per keystroke"]
L1[Map.get e.code → codeId]
L1 --> L2["(codeId << 4) | mods"]
L2 --> L3[Map.get hash]
endThe lite-hotkey hot path is exactly two Map.get calls and a couple of bitwise ops. The first lookup translates e.code to a per-physical-key integer id (interned at bind time). The second uses (codeId << 4) | modifierMask as a 32-bit hash to find the callback.
No allocations. No string churn. Bindings are computed once at bind() time and reused forever.
Install
npm i @zakkster/lite-hotkeyESM only. Zero runtime dependencies.
Quick start
import { HotkeyRouter } from '@zakkster/lite-hotkey';
const router = new HotkeyRouter();
router.bind('Ctrl+KeyZ', () => editor.undo());
router.bind('Shift+Ctrl+KeyZ', () => editor.redo());
router.bind('Ctrl+KeyS', () => save());
router.bind('Escape', () => closeModal());
router.bind('Slash', (e) => {
// Returning false skips preventDefault — the slash will type normally
if (document.activeElement === searchBox) return false;
searchBox.focus();
});
const handler = router.handleEvent.bind(router);
window.addEventListener('keydown', handler);
// Later, on cleanup:
window.removeEventListener('keydown', handler);
router.destroy();How it works
Hash layout
Every binding hashes to a 32-bit integer:
flowchart LR
Code[e.code → interned codeId<br/>1..255] --> Shift[shift left 4]
Mods["modifiers: shift|ctrl|alt|meta<br/>(4 bits)"] --> OR
Shift --> OR[OR]
OR --> Hash[hash key]- bits 0-3 — modifier mask (
shift=1,ctrl=2,alt=4,meta=8). - bits 4-11 — interned id for the physical key code (
'KeyA'→ 1,'KeyB'→ 2, ...).
The dictionary is module-scoped and shared across all router instances, which is fine because it can hold up to 255 distinct codes — there are only ~200 distinct values defined in the spec, and a typical app uses 20.
Bind path (cold)
router.bind('Ctrl+KeyZ', undo);
// 1. parse 'Ctrl+KeyZ' → mods=2, code='KeyZ'
// 2. intern 'KeyZ' → codeId=N
// 3. hash = (N << 4) | 2
// 4. bindings.set(hash, undo)This runs once per binding. String parsing is fine here — it's not the hot path.
Dispatch path (hot)
router.handleEvent(e);
// 1. mods = (e.shiftKey?1:0) | (e.ctrlKey?2:0) | (e.altKey?4:0) | (e.metaKey?8:0)
// 2. codeId = KEY_CODES.get(e.code) — early-out if undefined
// 3. cb = bindings.get((codeId << 4) | mods)
// 4. if (cb) preventDefault unless cb returns falseTwo Map.gets, a handful of bitwise ops, no allocation. If e.code was never bound by anyone, the first Map.get returns undefined and we bail before doing anything else — keystrokes for unmapped keys are essentially free.
API reference
new HotkeyRouter()
No arguments. State lives on this.bindings (a Map).
.bind(combo, callback)
| Param | Type | Notes |
|----------|--------------------------------------|---------------------------------------------|
| combo | string | [Modifier+]*Code. See gotcha at the top. |
| callback | (e: KeyboardEvent) => boolean\|void| Return false to skip preventDefault. |
- Throws
TypeErrorifcombois empty, has no key code component, orcallbackis not a function. - Replaces any existing binding for the same combo.
- Modifier names:
Shift,Ctrl/Control,Alt,Meta/Cmd. Case-insensitive. Order doesn't matter.
.unbind(combo)
Removes a binding. No-op if not bound. Same combo-string format as bind.
.clear()
Removes all bindings on this router instance. Other instances are unaffected.
.handleEvent(e)
The hot path. Wire it up:
window.addEventListener('keydown', router.handleEvent.bind(router));Bind once and store the bound reference if you intend to remove the listener later — calling .bind(this) on every registration would allocate.
.destroy()
Drops all bindings. Idempotent. Calling other methods afterwards is fine but pointless (nothing matches).
Edge cases & guarantees
- Combo with only modifiers (e.g.
'Ctrl+Shift'). Throws onbind— no key code means no possible match. - Combo with the literal
+key. Not supported; the parser splits on+. Bind'Equal'for the physical+/=key on a US layout. - Order independence.
'Ctrl+Shift+KeyZ'and'Shift+Ctrl+KeyZ'produce the same hash. - Modifier aliases.
Control≡Ctrl,Cmd≡Meta,Commandis not recognised — useCmdorMeta. - Strict modifier matching.
Ctrl+KeyZdoes NOT fire when the user holdsCtrl+Shift+KeyZ. If you want both, bind both — it's two entries in aMap, you can afford it. preventDefaultsemantics. Strict=== falsefrom the callback skips the call. Anything else (includingundefined,0,null) calls it. The default is "prevent" because that's what you want for app shortcuts; opt out per-callback when you genuinely want the browser's behaviour.- Code dictionary capacity. Up to 255 distinct
e.codevalues across the lifetime of the process. Throws if exceeded — practically unreachable, since the spec defines fewer than that. - Multiple instances. Each router has its own bindings. They share the global code-id dictionary, which is fine and intended.
FAQ
Why e.code and not e.key?
e.key is what character would be typed given the current layout and modifier state. That's perfect for autocomplete and bad for shortcuts: a Cyrillic user pressing the physical Z-key gets e.key === 'я', an AZERTY user pressing the same physical key gets 'w', and Shift+1 gives '!'. e.code describes the physical key and stays stable across all of that.
How do I handle "any character key" inputs (like search-as-you-type)?
Don't use this library for that. This is a hotkey router, not a text input handler. Listen for 'input' on the field instead.
Can I match keyup instead of keydown?
Yes — handleEvent doesn't care which event type fired it. Just attach the listener to whichever event you want. Most apps want keydown because it repeats and matches platform shortcut conventions.
What about scope / context (only fire when this element is focused)?
Out of scope for this library. The simplest pattern is to gate inside the callback:
router.bind('KeyJ', (e) => {
if (!editorIsFocused()) return false;
cursor.moveDown();
});Returning false lets the keystroke fall through to the browser, which is what you want when your shortcut doesn't apply.
Does it handle macOS' Cmd-vs-Ctrl convention for me?
No, that's a UX decision. Bind both if you want it cross-platform:
const undo = () => editor.undo();
router.bind('Ctrl+KeyZ', undo);
router.bind('Meta+KeyZ', undo);What about chord shortcuts (Ctrl+K Ctrl+S)?
Not supported. Single-key combos only. If you need chords, this is the wrong library.
License
MIT © Zahary Shinikchiev
