When you bind
Ctrl+Sto "save" in a web app, do you checkevent.key === "s"orevent.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 11node --testcases.
⌨️ 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();
}
}
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;
}
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));
}
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("+");
}
Two design choices:
- Letter and digit keys use
code(layout-stable across Dvorak, AZERTY, JIS). - Everything else uses
key(so arrow keys come through asArrowLeft, notArrowLeftorNumpad4ambiguously). - 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);
});
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. -
keyCodeandwhichare deprecated; their layout behaviour is incoherent enough that you shouldn't read them in new code. - IME composition fires
keydownfor every keystroke. Filter withevent.isComposing(modern) andevent.keyCode === 229(legacy Chrome marker). -
getModifierState()is the only way to readCapsLock,NumLock,AltGraph— flag fields don't cover them. -
locationdistinguishes left/right modifiers and main-row vs numpad keys. - Pulling the
KeyboardEventanalysis into pure helpers makes it testable undernode --testwith synthetic event literals — no headless browser, no DOM.
Full source on GitHub — event.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)