DEV Community

SEN LLC
SEN LLC

Posted on

A Live KeyboardEvent Inspector — Settling the `event.key` vs `event.code` Question for JS Shortcuts

When you bind Ctrl+S to "save" in a web app, do you check event.key === "s" or event.code === "KeyS"? The honest answer is "I don't remember, I copy-paste from Stack Overflow." Until a Dvorak user reports the shortcut is broken — or a Japanese IME user reports it fires mid-composition.

This is a live inspector for KeyboardEvent: press any key (or combination), see every field — key, code, keyCode, modifier state, IME composition flag, key location — light up at once. Built to make the answers obvious. ~150 lines of vanilla JS plus an 80-line pure helper module with 11 node --test cases.

key-event-viz UI: dark theme. After pressing Ctrl+Shift+S, the

⌨️ Demo: https://sen.ltd/portfolio/key-event-viz/
📦 GitHub: https://github.com/sen-ltd/key-event-viz

Why key and code both exist

They answer different questions:

  • event.key = the character produced (depends on layout + modifier state)
  • event.code = the physical key (independent of layout)

Pressing the physical S key on a US-QWERTY keyboard:

Modifiers event.key event.code
none "s" "KeyS"
Shift "S" "KeyS"
none, Dvorak layout "o" "KeyS"
none, AZERTY (French) "s" "KeyS"

The physical S key is always code: "KeyS" no matter the layout. key reflects whatever the OS layout + modifier state produces.

So:

  • Shortcuts: use event.code === "KeyS". A Dvorak user pressing what they think of as "the S key" still triggers your save handler.
  • Text input handlers: use event.key. You want what the user actually typed, not the physical key.
  • Named keys (arrows, Esc, F-keys): both work — key === "ArrowLeft", code === "ArrowLeft".

keyCode and which are deprecated

The numeric event.keyCode (83 for S) and event.which (same) were removed from the spec years ago. Every browser still emits them for compat — old code reads them — but don't write new code against them.

The reasons are baked-in confusion: physical S on Dvorak gives keyCode === 79 (because that's what the layout produces), Mac vs PC modifier semantics differ, JIS-keyboard symbol keys return wildly different numbers across OS. code is a string, layout-stable, in the spec, and readable in DevTools.

IME composition: keydown fires while you're not really typing

When a Japanese IME composes "konnichiwa → こんにちは", every romaji keystroke fires keydown, even though the user hasn't "typed" anything finalised yet. A naive Ctrl+S handler will fire mid-composition.

The modern signal is event.isComposing:

function onKeyDown(e) {
  if (e.isComposing) return;            // skip during IME composition
  if (e.ctrlKey && e.code === "KeyS") {
    e.preventDefault();
    save();
  }
}
Enter fullscreen mode Exit fullscreen mode

But Chromium browsers also emit a legacy marker — event.keyCode === 229 during IME composition keydowns — that predates the isComposing field. Belt and suspenders:

export function isImeComposition(event) {
  if (event.isComposing) return true;        // modern, per spec
  if (event.keyCode === 229) return true;    // legacy, still in Chrome
  return false;
}
Enter fullscreen mode Exit fullscreen mode

In key-event-viz, you can flip your IME on, type, and watch isComposing: true and keyCode: 229 light up at the same time. Different OS and IME combinations behave subtly differently (macOS "Live Conversion" toggle changes things) — testing with the actual user's combination matters.

Why getModifierState() instead of the boolean flags

The event.shiftKey / ctrlKey / altKey / metaKey boolean fields are convenient but incomplete:

  • CapsLock / NumLock / ScrollLock: lock-state modifiers that don't appear in the flag fields.
  • AltGraph: the AltGr key on European layouts (used to enter € or @ on German keyboards). Not in the flag fields.

These can only be read via getModifierState():

const modifiers = ["Shift", "Control", "Alt", "Meta", "AltGraph",
                   "CapsLock", "NumLock", "ScrollLock"];
for (const name of modifiers) {
  console.log(name, event.getModifierState(name));
}
Enter fullscreen mode Exit fullscreen mode

Showing "Caps Lock is on" near a password field — the canonical use case — needs getModifierState("CapsLock"). The tool surfaces all 8 in a grid so you can see which fire.

location: left vs right, main vs numpad

event.location is a numeric:

  • 0: STANDARD
  • 1: LEFT (left Shift, left Ctrl, etc.)
  • 2: RIGHT (right Shift, right Ctrl, etc.)
  • 3: NUMPAD (number-pad digits, Enter, +/−, etc.)

Useful for: distinguishing main-row 1 from numpad 1 in a game's key remapper, mapping left/right Shift to different actions for accessibility, or surfacing numpad-specific behaviour.

For numpad keys you actually get both signals — code: "Numpad1" and location: 3 (NUMPAD) — but location is the only way to tell left Shift from right Shift, since their code differs (ShiftLeft vs ShiftRight) but key is the same string.

Building the binding string

What shortcut libraries actually need is a canonical string for "the combination that just fired." Mousetrap, Tinykeys, and most others converge on Ctrl+Shift+KeyS style:

export function bindingString(event) {
  const mods = [];
  if (event.ctrlKey) mods.push("Ctrl");
  if (event.altKey) mods.push("Alt");
  if (event.shiftKey) mods.push("Shift");
  if (event.metaKey) mods.push("Meta");

  let key;
  if (event.code && /^Key[A-Z]$/.test(event.code)) {
    key = event.code;             // "KeyS"
  } else if (event.code && /^Digit\d$/.test(event.code)) {
    key = event.code;             // "Digit1"
  } else {
    key = event.key;              // "ArrowLeft" / "Escape"
  }
  return [...mods, key].filter(Boolean).join("+");
}
Enter fullscreen mode Exit fullscreen mode

Two design choices:

  • Letter and digit keys use code (layout-stable across Dvorak, AZERTY, JIS).
  • Everything else uses key (so arrow keys come through as ArrowLeft, not ArrowLeft or Numpad4 ambiguously).
  • Modifier order is fixed (Ctrl, Alt, Shift, Meta) so the same combination always serializes the same way — important for matching a user's binding lookup table.

DOM-free helpers + node --test

The interesting logic — modifier extraction, binding strings, IME detection, key/code divergence checking — is in event.js as plain functions taking any { key, code, keyCode, ... } literal. Zero DOM dependency, so the tests run under node --test against synthetic objects:

test("isImeComposition catches both modern and legacy markers", () => {
  assert.equal(isImeComposition(ev()), false);
  assert.equal(isImeComposition(ev({ isComposing: true })), true);
  // Legacy: Chrome still emits keyCode=229 during IME on keydown.
  assert.equal(isImeComposition(ev({ keyCode: 229 })), true);
  // A real Enter at IME confirm has keyCode=13 — NOT IME.
  assert.equal(isImeComposition(ev({ key: "Enter", keyCode: 13 })), false);
});
Enter fullscreen mode Exit fullscreen mode

11 cases total, no headless browser needed. The DOM script (script.js) becomes "wire events into helpers, render the result," and the helpers' tests double as documentation for what each KeyboardEvent field really means.

Takeaways

  • event.key = produced character (layout-dependent). event.code = physical key (layout-stable). Shortcuts → code, text input → key.
  • keyCode and which are deprecated; their layout behaviour is incoherent enough that you shouldn't read them in new code.
  • IME composition fires keydown for every keystroke. Filter with event.isComposing (modern) and event.keyCode === 229 (legacy Chrome marker).
  • getModifierState() is the only way to read CapsLock, NumLock, AltGraph — flag fields don't cover them.
  • location distinguishes left/right modifiers and main-row vs numpad keys.
  • Pulling the KeyboardEvent analysis into pure helpers makes it testable under node --test with synthetic event literals — no headless browser, no DOM.

Full source on GitHubevent.js is the 80-line pure module, tests/event.test.js has 11 cases. MIT.

Live demo — click into the capture area and start pressing keys. Try Dvorak / AZERTY layouts (or just toggle the OS layout temporarily) to see key and code diverge in real time.

Top comments (0)