A tiny keyboard-shortcut dispatcher: bind keys (and multi-key sequences) to actions, with stackable layers for modal UIs.
- ~3.27 kB minified, ~1.54 kB gzipped
- Zero dependencies
- Single-key, modifier, and multi-key Vim-style sequences (
g g,d w) - Stackable layers (
push/pop) for modes and contextual overrides - Works with raw
KeyboardEvents, withpreventDefaulthandled for you
import{Keymap}from"@nocksock/keymap"consteditor=newKeymap({'j': ()=>moveDown(),'k': ()=>moveUp(),'ctrl+s': ()=>save(),})document.addEventListener('keydown',editor.handleKeyboardEvent)Tip: the test suite (
test/) doubles as documentation — it's the most thorough, runnable set of usage examples, covering every feature and edge case described below.
A binding map pairs a key string with an action. An action is either a plain function or an object with an effect:
constkm=newKeymap({'a': ()=>doThing(),'b': {group: 'edit',description: 'do other thing',effect: ()=>doOther()},})Passing anything else (e.g. a number) throws:
newKeymap({'a': 123})// throws| Form | Example | Matches |
|---|---|---|
| Single key | 'a' | the a key |
| Modifier | 'ctrl+b', 'shift+h' | modifier + key |
| Named key | 'escape', 'space' | Escape, the spacebar |
| Sequence | 'g g', 'g a b' | keys pressed in order |
Modifiers and named keys register in canonical lowercase. A Shift+H press matches a 'shift+h' binding; a spacebar press matches 'space'.
Keys are case-insensitive (a capital letter is shift+<letter>). Any single character below, or a named key, is valid:
| Group | Keys |
|---|---|
| Letters | a–z |
| Digits | 0–9 |
| Symbols | `~!@#$%^&*()-=[]{}\|;:'",.<>/?_ |
| Named | spacetabenterbackspacedeleteescape (alias esc) homeendpageuppagedown |
| Arrows | arrowuparrowdownarrowleftarrowright (aliases updownleftright) |
| Function | f1–f12 |
| Modifiers | ctrlcmd (alias meta) shiftaltsuper (cmd on macOS, ctrl elsewhere) |
Notes:
- Combine modifiers with
+(or-, e.g.ctrl-s); order doesn't matter (shift+ctrl+a===ctrl+shift+a). - Aliases resolve to their canonical key (
up→arrowup,esc→escape), so a binding and the matching event always agree. superresolves per-OS, sosuper+sis ⌘S on macOS and Ctrl+S elsewhere.- The literal
+key can't be expressed (it's the separator) — use the other keys around it.-works as a key (e.g.ctrl+-).
Feeds one key to the map and runs the matching action. The key can be a string or a KeyboardEvent-shaped object.
km.type('a')km.type('ctrl+b')km.type(escapeEvent)// { key: 'Escape', ... }type returns the outcome:
| Result | Meaning |
|---|---|
'handled' | a binding matched and fired |
'unhandled' | nothing matched |
'pending' | a multi-key sequence is partway through |
The result comes back synchronously, so you can drive UI from it — surface a which-key-style hint or a "waiting for the next key" indicator while a sequence is 'pending', clear it on 'handled', and optionally flash feedback on 'unhandled'.
constkm=newKeymap({'g g': goTop,'g e': goEnd})km.type('g')// 'pending' — waiting for the next keykm.type('g')// 'handled' — fires goTopA broken sequence resets and reports 'unhandled':
km.type('g')// 'pending'km.type('x')// 'unhandled' — 'g x' matches nothing; buffer resetsA key that is a prefix of a longer binding waits instead of firing early — even when it is the only candidate. This is what makes operator + motion (e.g. d w) work:
constkm=newKeymap({'d w': deleteWord})km.type('d')// 'pending' — never fires on its ownkm.type('w')// 'handled' — fires deleteWordThe same holds when one map defines both a key and a longer sequence starting with it:
constkm=newKeymap({'g': goLine,'g g': goTop})km.type('g')// 'pending' — won't fire goLine earlykm.type('g')// 'handled' — fires goTopThe DOM entry point. It dispatches the event and calls preventDefault() for matched keys (see below). Lone modifier presses (a bare Shift, Ctrl, …) are ignored and never pollute a pending sequence:
km.handleKeyboardEvent(gEvent)// 'pending'km.handleKeyboardEvent(shiftEvent)// ignoredkm.handleKeyboardEvent(gEvent)// completes 'g g'handleKeyboardEvent is permanently bound to its keymap, so you can pass it straight to addEventListener / removeEventListener — no wrapping arrow, no .bind. Attach it to document, window, or any element, and detaching is symmetric:
// in a custom elementconnectedCallback(){this.addEventListener('keydown',this.km.handleKeyboardEvent)}disconnectedCallback(){this.removeEventListener('keydown',this.km.handleKeyboardEvent)}Effects receive a context object. Anything you pass as the second type argument is exposed as context:
constkm=newKeymap({'a': (ctx)=>console.log(ctx.context)})km.type('a',{selection: '…'})// ctx.context === { selection: '…' }Object-form bindings receive the same context as plain functions.
Matched keys call event.preventDefault() by default; unmatched keys pass through untouched.
Opt out per binding:
newKeymap({'a': {preventDefault: false,effect: doThing}})Or opt out at runtime, for one press only:
newKeymap({'a': (ctx)=>ctx.permitDefault()})permitDefault() is not sticky — a later press of the same key prevents the default again.
By default only matched keys prevent the default — an incomplete sequence (the g of g g) passes through. Opt in to prevent the default on pending keys too, so a half-typed prefix never leaks a browser shortcut. Set it for the whole keymap, or per binding:
newKeymap({'g g': goTop},{pendingPreventDefault: true})// keymap-widenewKeymap({'g g': {pendingPreventDefault: true,effect: goTop}})// single bindingLayers stack on top of the base map for modal behaviour. The topmost layer wins; keys it doesn't define fall through to layers below.
constkm=newKeymap({'j': moveDown,'k': moveUp})km.push({'j': nudgeSelection})// overrides only 'j'km.type('j')// nudgeSelectionkm.type('k')// moveUp — fell throughkm.pop()// back to the base 'j'push and pop are LIFO, and push is chainable. pop() on an empty stack is a no-op — it never removes the base, and over-popping never corrupts the stack.
A normal push shadows: keys the layer doesn't define fall through to layers below. An exclusive layer doesn't — it hides everything beneath it, so only its own keys resolve. Use it for truly modal states (a command palette, a confirm prompt) where the base bindings should be unreachable.
constkm=newKeymap({'j': moveDown,'k': moveUp})km.push({'x': confirm},{exclusive: true})km.type('j')// 'unhandled' — base is hidden, no fall-throughkm.type('x')// confirmkm.pop()// base reachable (and merging) againlist() reflects only the exclusive layer while it's active. Exclusive and shadowing layers stack together in LIFO order — an exclusive layer hides everything below it until it's popped.
An effect can push a layer; it takes effect for the next key:
constkm=newKeymap({'i': ()=>km.push({'escape': ()=>km.pop(),'x': insertX}),})km.type('i')// enter "insert mode"km.type('x')// insertXThe topmost layer that has any candidate — complete or partial — for the current buffer owns the resolution. Lower layers can't extend or complete a buffer the top already claims. This keeps sequence resolution decidable without timers.
// Top defines 'g' as complete → it shadows a base 'g g' entirelyconstkm=newKeymap({'g g': goTop})km.push({'g': goLine})km.type('g')// 'handled' — goLine; base 'g g' is unreachable while pushed// Top defines 'g g' → it claims the 'g' prefix, shadowing a base 'g'constkm2=newKeymap({'g': goLine})km2.push({'g g': goTop})km2.type('g')// 'pending' — top owns the prefix, base 'g' stays shadowedkm2.type('g')// 'handled' — goTopPopping the layer restores the base behaviour exactly.
Returns the stored binding for a key (or undefined). Object-form bindings keep their group and description:
km.get('a')// { group: 'nav', description: 'do a', effect: … }Returns one entry per active binding — { keys, group?, description? } — for building help overlays or cheat sheets. It is stack-aware: a pushed layer shadows the base for the same key, so each key appears once, with the active layer's metadata. Re-read it after each push/pop to keep a live which-key panel or cheat sheet in sync with the current mode.
constkm=newKeymap({'j': {group: 'nav',description: 'down',effect: moveDown},'k': {group: 'nav',description: 'up',effect: moveUp},})km.list()// [// { keys: 'j', group: 'nav', description: 'down' },// { keys: 'k', group: 'nav', description: 'up' },// ]Returns the active resolution map (base merged with pushed layers).
Replaces the entire base map. It also clears any pushed layers and resets the pending buffer, giving you a clean slate. Chainable.
km.load({'b': newAction})// old bindings gone, stack cleared.load({/* … */})// returns the keymapOverwrites or adds a single binding.
km.set('x',replacement)Cancels an in-progress sequence without touching pushed layers. It's detached from this, so you can wire it straight to a blur listener:
input.addEventListener('blur',km.reset)km.type('g')// 'pending'km.reset()// buffer cleared; layers untouchedkm.type('g')// a fresh first 'g'- Re-entrant effects are safe: an effect may call
typeagain without corrupting the buffer. - The pending buffer stays bounded — sustained unmatched input always resets it, never grows it.
- Popped layers are released for garbage collection.
| Member | Returns | Description |
|---|---|---|
new Keymap(bindings?, options?) | Keymap | Create a keymap, optionally seeded with a binding map. Options: { pendingPreventDefault }. |
set(key, action) / set(map) | this | Add or overwrite a single binding, or a whole map. |
type(key, ctx?) | 'handled' | 'pending' | 'unhandled' | Dispatch one key (string or KeyboardEvent) and run the match. |
handleKeyboardEvent(event) | void | DOM handler — dispatches and calls preventDefault() for matches. Permanently bound. |
reset() | void | Clear the pending sequence buffer. Permanently bound. |
push(map, options?) | this | Push a layer. { exclusive: true } hides everything below it. |
pop() | this | Remove the top layer (no-op on an empty stack). |
load(bindings) | this | Replace the base map; also clears layers and the buffer. |
get(key) | binding | undefined | The stored binding for a key. |
list() | { keys, group?, description? }[] | Active bindings, stack-aware — for help overlays. |
current() | Map | The active resolution map (base merged with layers). |
context | UserContext | Property passed to effects dispatched via handleKeyboardEvent. |
This library is artisanal, hand-written code. Every line of the library source (src/) is 100% human-written — no LLM-generated implementation. LLM assistance was used only to help write the test suite and this README; the design and implementation are entirely human.