JavaScript Keyboard Events: event.key vs event.code vs keyCode
Keyboard handling in JavaScript has three distinct generations of APIs, and picking the wrong one still causes bugs in 2026 — especially for users on non-US keyboard layouts or macOS. Before writing any shortcut handler, open the Keyboard Event Tester and press a few keys. You will immediately see the difference between key, code, and the legacy keyCode on your own machine.
Three Generations of Keyboard APIs
The browser keyboard API evolved through three layers, each introduced to fix problems in the previous one:
- DOM Level 0 —
event.which: A Netscape-era property that returned different values in different browsers. Never standardized. Avoid entirely. - DOM Level 3 (legacy) —
event.keyCode/event.charCode: Numeric codes standardized by Internet Explorer and carried into the spec for compatibility.keyCodewas officially marked deprecated in the DOM Level 3 spec. All modern browsers still fire it, but you should not write new code against it. - DOM Level 3 (current) —
event.key/event.code: Two separate string-based properties introduced to cleanly separate the logical character from the physical key position. These are what you should use.
Comparison: What Each Property Returns
The table below shows what each property returns when a user presses Shift+A on three different keyboard layouts:
| Property | QWERTY (Shift+A) | AZERTY (Shift+Q) | Dvorak (Shift+A) |
|---|---|---|---|
event.keyCode | 65 | 65 | 65 |
event.key | "A" | "A" | "A" |
event.code | "KeyA" | "KeyQ" | "KeyA" |
On AZERTY, the physical key that sits where A is on QWERTY is labeled Q. So event.code reports "KeyQ" (the physical position), while event.key still reports "A" (the character produced after Shift is applied). The numeric keyCode happens to agree here — 65 — but breaks badly on special characters and non-Latin layouts.
event.code — Physical Key Location
event.code identifies the physical key on the keyboard, completely independent of the active layout. The value is a string like "KeyA", "ArrowLeft", "Space", or "Digit1". Because it maps to position, not character, it is the right choice for:
- Game controls — WASD movement works correctly even when the player switches to a French AZERTY layout, where those letters sit in different positions. Using
event.keyfor WASD would break immediately. - Hotkeys that should not shift with layout — shortcuts like Ctrl+Z, Ctrl+S where the letter is chosen for muscle memory, not the character.
// Game movement — use event.code, not event.key
window.addEventListener('keydown', (e) => {
switch (e.code) {
case 'KeyW': moveForward(); break;
case 'KeyA': moveLeft(); break;
case 'KeyS': moveBack(); break;
case 'KeyD': moveRight(); break;
}
});event.key — The Character Produced
event.key returns the string value of the key after the active modifier state is applied. For printable characters this is the character itself ("a", "A", "€"). For non-printable keys it is a named value: "Enter", "Backspace", "ArrowUp", "Escape".
event.key is the right choice when you care about the character the user intends to produce — text input validation, autocomplete trigger keys, or shortcuts that should follow the user's layout. It is also IME-aware: during composition (typing CJK characters with an input method editor), event.key returns "Process", which lets you skip shortcut logic during composition.
Deprecated Numeric Codes — Why They Still Appear
Despite being deprecated, event.keyCode is still fired by every browser and is unlikely to be removed. The reasons you still encounter it:
- Legacy codebases pre-dating 2016 DOM Level 3 adoption use numeric codes throughout.
- Some internal enterprise tooling was frozen at IE11 compatibility. IE11 does not implement
event.codeat all, so the only cross-IE option waskeyCode. - Tutorials and Stack Overflow answers from before 2016 proliferate numeric constants without deprecation warnings.
If you genuinely need to support IE11 (rare in 2026, but still common in regulated industries), you can polyfill event.code from keyCode using a lookup table, or use a library that abstracts the difference.
Modifier Key Normalization: Mac vs Windows/Linux
The largest cross-platform keyboard headache is the Command key on macOS. Mac users expect Cmd+S to save; Windows and Linux users expect Ctrl+S. The browser exposes four boolean modifier properties on every keyboard event:
event.metaKey—truewhen Command (Mac) or Windows key is held. On Mac this is the shortcut modifier.event.ctrlKey—truewhen Control is held. On Windows/Linux this is the shortcut modifier; on Mac it is a secondary modifier rarely used for application shortcuts.event.altKey— Option on Mac, Alt on Windows/Linux.event.shiftKey— Shift on all platforms.
The correct cross-platform pattern is to check metaKey on Mac and ctrlKey everywhere else. The platform detection approach:
const isMac = navigator.platform.startsWith('Mac')
|| navigator.userAgentData?.platform === 'macOS';
function isPrimaryModifier(e) {
return isMac ? e.metaKey : e.ctrlKey;
}Keyboard Shortcut Libraries
Rolling your own shortcut system is error-prone. Three popular libraries handle the normalization for you:
| Library | Size | Best for | Notes |
|---|---|---|---|
| hotkeys-js | ~2 kB | Vanilla JS, any framework | Simple API, scope support, no dependencies |
| react-hotkeys-hook | ~3 kB | React hooks | Respects React lifecycle; disables in inputs by default |
| Tinykeys | ~0.6 kB | Minimal footprint | Key sequence support (g i style), uses event.key |
// hotkeys-js
import hotkeys from 'hotkeys-js';
hotkeys('ctrl+s, command+s', (e) => {
e.preventDefault();
save();
});
// react-hotkeys-hook
import { useHotkeys } from 'react-hotkeys-hook';
useHotkeys('mod+s', () => save(), { preventDefault: true });
// Tinykeys
import { tinykeys } from 'tinykeys';
tinykeys(window, {
'$mod+s': (e) => { e.preventDefault(); save(); },
});All three treat mod or command as the platform-appropriate primary modifier, handling the Mac/Windows split automatically.
Common Pitfalls
- Forgetting preventDefault for browser shortcuts: Binding
Ctrl+Swithout callinge.preventDefault()will both trigger your handler and open the browser's Save dialog. - International keyboards and special characters: On a German keyboard,
/requires Shift+7. If you match onevent.key === "/"you are fine; if you match onevent.code === "Slash"the German user needs to use Shift+7 consciously. Decide which behavior you want. - Dead keys: Keys like
^or`on European layouts are dead keys — they do not produce a character on their own but combine with the next keystroke.event.keyreturns"Dead"for these. Handle or ignore them explicitly. - keydown vs input event ordering: The
keydownevent fires before the input value changes. If you readinput.valueinside a keydown handler, you see the value before the keystroke. Use theinputevent when you need the updated value. - Shortcuts firing inside inputs: Global shortcut listeners catch keystrokes even when focus is inside a text input or contenteditable. Guard with
e.target.tagNameor use a library that handles this.
Full Cross-Platform Shortcut Matcher
The function below combines platform detection, modifier normalization, and IME-awareness into a reusable shortcut handler:
const isMac =
typeof navigator !== 'undefined' &&
(navigator.platform.startsWith('Mac') ||
navigator.userAgentData?.platform === 'macOS');
/**
* Returns true when the keyboard event matches the shortcut descriptor.
*
* descriptor examples:
* 'mod+s' → Cmd+S on Mac, Ctrl+S elsewhere
* 'mod+shift+k' → Cmd+Shift+K on Mac, Ctrl+Shift+K elsewhere
* 'escape' → Escape key, no modifiers
*/
function matchesShortcut(e, descriptor) {
// Skip during IME composition
if (e.isComposing || e.key === 'Process') return false;
const parts = descriptor.toLowerCase().split('+');
const key = parts[parts.length - 1];
const wantsMod = parts.includes('mod');
const wantsShift = parts.includes('shift');
const wantsAlt = parts.includes('alt');
const primaryModifier = isMac ? e.metaKey : e.ctrlKey;
const conflictingMod = isMac ? e.ctrlKey : e.metaKey;
if (wantsMod !== primaryModifier) return false;
if (conflictingMod) return false;
if (wantsShift !== e.shiftKey) return false;
if (wantsAlt !== e.altKey) return false;
return e.key.toLowerCase() === key;
}
// Usage
document.addEventListener('keydown', (e) => {
if (matchesShortcut(e, 'mod+s')) {
e.preventDefault();
save();
}
if (matchesShortcut(e, 'mod+shift+k')) {
e.preventDefault();
deleteLine();
}
});Use the Keyboard Event Tester to inspect the exact key, code, keyCode, and modifier flags your browser fires for any keystroke — useful when debugging layout-specific behavior or verifying shortcut logic before shipping.