Appearance
Keyboard shortcut listener
Declarative, element-scoped keyboard shortcut binding with a chainable API and a proxy-based key descriptor that prevents typos and enables autocomplete.
When to use
Use when binding keyboard shortcuts to specific elements — navigation grids, sidebars, search inputs, or the component root. Replaces manual addEventListener("keydown", ...) + event.key === + event.preventDefault() boilerplate.
API
KEY — proxy-based key descriptors
KEY is a Proxy that builds key descriptors through property access. No strings to typo, IDE autocomplete works naturally.
javascript
KEY.Escape // Escape key
KEY.Esc // alias for Escape
KEY.ArrowDown // standard name
KEY.Down // short alias
KEY.Enter // Enter key
KEY.Mod.F // Cmd+F on Apple, Ctrl+F elsewhere
KEY.Mod.Shift.S // Cmd+Shift+S on Apple, Ctrl+Shift+S elsewhere
KEY.Alt.D // Alt+DModifiers: Mod (platform primary), Shift, Alt.
Mod resolves to Meta (⌘) on Apple platforms, Control on Windows/Linux.
Letter keys match case-insensitively — KEY.Mod.F and KEY.Mod.f both match Cmd+F. Use KEY.Mod.Shift.F for Cmd+Shift+F.
Short aliases:
| Short | Standard |
|---|---|
Esc | Escape |
Up | ArrowUp |
Down | ArrowDown |
Left | ArrowLeft |
Right | ArrowRight |
Del | Delete |
Ins | Insert |
Both forms work: KEY.Esc and KEY.Escape are equivalent.
KeyListener — element-scoped bindings
Construct with an element. Chainable .keydown() registers bindings. Auto-prevents default on match. First match wins.
javascript
import { KEY, KeyListener } from "./key-listener.js";
new KeyListener(searchInput)
.keydown(KEY.Down, () => moveGrid("down"))
.keydown(KEY.Up, () => moveGrid("up"))
.keydown(KEY.Enter, () => copySelected())
.keydown(KEY.Esc, () => clearSearch());The handler receives the KeyboardEvent if needed:
javascript
new KeyListener(editor)
.keydown(KEY.Mod.S, (event) => {
save(event.target);
});Teardown
.off() removes the event listener and clears all bindings:
javascript
const keys = new KeyListener(element)
.keydown(KEY.Esc, close);
// later
keys.off();Full example
A component with three scoped keyboard contexts: a search input for grid navigation, a sidebar nav with roving focus, and a global find shortcut.
javascript
import { KEY, KeyListener } from "./key-listener.js";
// Grid navigation from search input
new KeyListener(this.#input)
.keydown(KEY.Down, () => this.#moveGrid("down"))
.keydown(KEY.Up, () => this.#moveGrid("up"))
.keydown(KEY.Right, () => this.#moveGrid("right"))
.keydown(KEY.Left, () => this.#moveGrid("left"))
.keydown(KEY.Enter, () => this.#copySelected())
.keydown(KEY.Esc, () => this.#clearSearch());
// Sidebar nav with roving tabindex
new KeyListener(this.#blocksNav)
.keydown(KEY.Down, () => this.#moveNav(1))
.keydown(KEY.Right, () => this.#moveNav(1))
.keydown(KEY.Up, () => this.#moveNav(-1))
.keydown(KEY.Left, () => this.#moveNav(-1))
.keydown(KEY.Home, () => this.#focusNavStart())
.keydown(KEY.End, () => this.#focusNavEnd());
// Global find shortcut on component root
new KeyListener(this)
.keydown(KEY.Mod.F, () => {
this.#input.focus();
this.#input.select();
});Implementation
javascript
const IS_APPLE = /Mac|iPhone|iPad/.test(
navigator.platform,
);
const ALIASES = {
Esc: "Escape",
Up: "ArrowUp",
Down: "ArrowDown",
Left: "ArrowLeft",
Right: "ArrowRight",
Del: "Delete",
Ins: "Insert",
};
function resolveKey(name) {
return ALIASES[name] ?? name;
}
function descriptorFor(chain) {
const mods = {
mod: false,
shift: false,
alt: false,
};
let key = null;
for (const part of chain) {
if (part === "Mod") {
mods.mod = true;
} else if (part === "Shift") {
mods.shift = true;
} else if (part === "Alt") {
mods.alt = true;
} else {
key = resolveKey(part);
}
}
return { ...mods, key };
}
function matches(event, descriptor) {
const modPressed = IS_APPLE ?
event.metaKey : event.ctrlKey;
if (descriptor.mod !== modPressed) return false;
if (descriptor.shift !== event.shiftKey) {
return false;
}
if (descriptor.alt !== event.altKey) return false;
if (descriptor.key.length === 1) {
return event.key.toLowerCase()
=== descriptor.key.toLowerCase();
}
return event.key === descriptor.key;
}
const KEY = new Proxy({}, {
get(_target, prop) {
return buildChain([prop]);
},
});
function buildChain(chain) {
const descriptor = descriptorFor(chain);
return new Proxy(descriptor, {
get(target, prop) {
if (
prop === "key" || prop === "mod"
|| prop === "shift" || prop === "alt"
) {
return target[prop];
}
return buildChain([...chain, prop]);
},
});
}
class KeyListener {
#element;
#bindings = [];
#handler;
constructor(element) {
this.#element = element;
this.#handler = (event) => {
for (
const [descriptor, callback] of
this.#bindings
) {
if (matches(event, descriptor)) {
event.preventDefault();
callback(event);
return;
}
}
};
this.#element.addEventListener(
"keydown", this.#handler,
);
}
keydown(descriptor, callback) {
this.#bindings.push([descriptor, callback]);
return this;
}
off() {
this.#element.removeEventListener(
"keydown", this.#handler,
);
this.#bindings = [];
return this;
}
}
export { KEY, KeyListener };Design decisions
- Auto-preventDefault: every matched shortcut prevents default. If you need a binding that doesn't prevent default, handle it outside
KeyListener. - First match wins: bindings are checked in registration order. The first matching descriptor stops further checks.
- Proxy-based KEY: avoids string parsing, enables autocomplete, catches typos at access time rather than runtime matching failures.
- Element-scoped: each
KeyListenerowns one element. Different elements get different instances — no global registry. ModnotCtrl/Meta: shortcuts should express intent ("the platform shortcut modifier") not mechanism ("the Control key").