Skip to content

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+D

Modifiers: 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:

ShortStandard
EscEscape
UpArrowUp
DownArrowDown
LeftArrowLeft
RightArrowRight
DelDelete
InsInsert

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 KeyListener owns one element. Different elements get different instances — no global registry.
  • Mod not Ctrl/Meta: shortcuts should express intent ("the platform shortcut modifier") not mechanism ("the Control key").