npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-hotkey

v1.0.0

Published

Zero-GC keyboard router with bitmasked modifier matching. Layout-independent — uses KeyboardEvent.code.

Readme

@zakkster/lite-hotkey

npm version npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

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

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]
    end

The 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-hotkey

ESM 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 false

Two 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 TypeError if combo is empty, has no key code component, or callback is 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 on bind — 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. ControlCtrl, CmdMeta, Command is not recognised — use Cmd or Meta.
  • Strict modifier matching. Ctrl+KeyZ does NOT fire when the user holds Ctrl+Shift+KeyZ. If you want both, bind both — it's two entries in a Map, you can afford it.
  • preventDefault semantics. Strict === false from the callback skips the call. Anything else (including undefined, 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.code values 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