How to Add Keyboard Shortcuts to a Web App
How to add keyboard shortcuts to a web app with document-level keydown listeners, typing guards, Mac and Windows modifiers, sequences, and React cleanup.
To add keyboard shortcuts to a web app, attach one keydown listener to document, match on event.key plus the modifier booleans, skip the event when its target is an editable element, and remove the listener with the same function reference when the owning component unmounts.
The first shortcut is usually quick to write. The trouble tends to arrive later: someone types “k” into a search box and the command palette opens, or a colleague on a Mac finds that the shortcut does nothing at all.
This article starts from that naive listener and fixes each failure in turn: firing while typing, Mac versus Windows modifiers, two-key sequences, listener leaks in React, and the accessibility rules that apply specifically to shortcuts. Most of the fixes are a few lines of TypeScript you can drop into an existing handler.
Key Takeaways
- A
keydownlistener ondocumentreceives every keystroke on the page, so the handler must return early whenevent.targetis aninput,textarea,select, or any element whoseisContentEditableis true. - A shortcut that tests only
event.ctrlKeynever fires on a Mac, because the Command key setsevent.metaKey; testevent.metaKey || event.ctrlKeyso one binding covers both platforms. - Matching does not need platform detection; resolve the platform only for display, which is the one use MDN documents for
navigator.platform. - A sequence like
gthenineeds a buffer, an expiry timeout, a reset on any non-prefix key, and a re-check of that key as the start of a new sequence. - In React, register the listener in
useEffect, remove the same reference in the cleanup, and memoize the handler withuseCallbackif it reads props or state.
The Naive JavaScript Keyboard Shortcuts Listener
The simplest working shortcut is a keydown listener that compares event.key against a character and calls preventDefault() on a match. Use event.key, never the obsolete keyCode.
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
openCommandPalette();
}
});
Lowercasing event.key makes the match survive Caps Lock and Shift. Everything else about this listener is a bug waiting for a user.
How Do You Stop Shortcuts Firing While the User Is Typing?
A shortcut handler must check event.target before doing anything, because a document-level listener also receives the keystrokes a user types into a search field. The common filter checks three tag names, and that mental model is the hole: a contenteditable region keeps its own tag (usually div), so a rich-text editor passes the check and the shortcut fires mid-sentence. Session replays of apps with global shortcuts surface exactly this: a user typing into a field, and the page navigating away on the letter that happened to be bound.
Use isContentEditable instead. It is true for any element the user can edit, including one that inherits editing from an ancestor:
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|| target.isContentEditable;
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target)) return;
// matching below
});
Return at the top of the handler so nothing downstream, including the sequence buffer in a later section, ever sees a typed keystroke.
How Do You Handle Mac and Windows Modifier Keys?
A shortcut that tests only event.ctrlKey is dead on a Mac, because the Command key sets event.metaKey. Accept either modifier when matching:
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }
This over-matches slightly (Ctrl+K also works on a Mac), which is harmless. What it avoids is platform detection in the match path. navigator.platform is documented as unreliable for detection, and the one use MDN endorses is choosing between ⌘ and Ctrl when showing a shortcut to the user. Keep it there:
| Physical key | Event property | Display |
|---|---|---|
| Command (macOS) | metaKey | ⌘ |
| Control (Windows/Linux) | ctrlKey | Ctrl |
| Windows key | metaKey | Do not bind |
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
keys.map((k) => (k === 'mod' ? (isMac ? '⌘' : 'Ctrl') : k)).join(isMac ? '' : '+');
How Do You Support Key Sequences Like g Then i?
A two-key sequence needs a buffer, a timeout that clears it, a reset when the buffer stops being a valid prefix, and a re-check of the offending key as the start of a new sequence. Dropping that key forces the user to press it twice. Two more rules: ignore keydowns where event.repeat is true so a held key does not flood the buffer, and ignore modifier-only keydowns (Shift, Control, Meta, Alt, AltGraph), or pressing Shift before a chord cancels any sequence in progress.
| Buffer | Result after pushing key | Action |
|---|---|---|
| any | equals a binding | run it, clear buffer |
| any | prefix of a binding | keep buffer, restart timeout |
| length > 1 | matches nothing | clear buffer, feed the key again alone |
| length 1 | matches nothing | clear buffer |
| any | timeout fires | clear buffer |
type Binding = { keys: string[]; description: string; run: () => void };
const bindings: Binding[] = [
{ keys: ['g', 'i'], description: 'Go to inbox', run: () => navigate('/inbox') },
{ keys: ['g', 'p'], description: 'Go to projects', run: () => navigate('/projects') },
];
const MODIFIERS = new Set(['Control', 'Meta', 'Shift', 'Alt', 'AltGraph']);
let buffer: string[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;
function reset() { buffer = []; clearTimeout(timer); }
function feed(key: string) {
buffer.push(key);
const exact = bindings.find(
(b) => b.keys.length === buffer.length && b.keys.every((k, i) => k === buffer[i]),
);
if (exact) { exact.run(); reset(); return; }
if (bindings.some((b) => buffer.every((k, i) => b.keys[i] === k))) {
clearTimeout(timer);
timer = setTimeout(reset, 800);
return;
}
const retry = buffer.length > 1;
reset();
if (retry) feed(key);
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target) || event.repeat || MODIFIERS.has(event.key)) return;
if (event.metaKey || event.ctrlKey || event.altKey) return; // chords go elsewhere
feed(event.key.toLowerCase());
});
The 800 ms window is a choice, not a measurement; a few hundred milliseconds is typical.
How Do You Register and Clean Up a Shortcut Listener in React?
In React, add the listener inside useEffect and remove the same function reference in its cleanup; if the handler reads props or state, memoize it with useCallback and list it in the effect’s dependency array. Without the cleanup, every re-mount stacks another listener and one keypress runs the action twice.
function useShortcuts(bindings: Binding[]) {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isTyping(event.target)) return;
// match against bindings here
}, [bindings]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
Watch the bindings dependency in that hook. If a caller passes an array literal inline, it is a different array on every render, so handleKeyDown changes identity and the effect removes and re-adds the listener each time. Nothing breaks, but the churn is wasted work. Declare the array at module level, or wrap it in useMemo in the calling component.
Since React 18, Strict Mode puts every Effect through one extra round of setup and teardown in development, so a cleanup that removes a different reference than the one it added shows up as a doubled handler straight away. Outside React the rule is identical: one addEventListener, one matching removeEventListener, same function.
Keep Shortcuts Accessible and Discoverable
Three rules apply specifically to shortcuts. Do not bind combinations the browser reserves, including Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+T, and Tab, and do not call preventDefault() on native editing chords such as Cmd/Ctrl+C. Never make a shortcut the only route to a feature; a menu item or button must exist for the same action. And for single-character bindings, WCAG 2.1 SC 2.1.4 Character Key Shortcuts (Level A) asks for one of three things: a way for people to switch the shortcut off, a way to rebind it so that it includes a key such as Ctrl or Alt, or a scope narrow enough that it only fires while its own component holds focus.
For discoverability, bind ? to a help dialog that renders the same bindings array the matcher uses. Match on event.key === '?' rather than Shift plus the slash key, so it works on layouts where ? sits on a different physical key.
if (event.key === '?' && !isTyping(event.target)) {
event.preventDefault();
helpDialog.showModal();
}
// inside the dialog
{bindings.map((b) => (
<li key={b.keys.join(' ')}><kbd>{formatKeys(b.keys)}</kbd> {b.description}</li>
))}
showModal() on a native <dialog> gives you Escape handling for free; for focus management inside it, see the guide to common accessibility issues with modals.
When Should You Reach for a Shortcuts Library?
Once you have more than a couple of bindings, scoping, conflict detection, and sequence handling are worth delegating. TanStack Hotkeys is one option: a Mod key in a binding resolves to Command on a Mac and to Control everywhere else, and keystrokes aimed at focused input elements are skipped for you. Its overview page still labels the library alpha and warns that the API may change, so pin your version and expect churn.
Conclusion
Shortcuts break in predictable places: the event target, the modifier key, the sequence buffer, and the listener lifecycle. Start with the isTyping guard and the metaKey || ctrlKey match in the handler you already have, then move your bindings into a single array so the matcher and the ? help dialog read from the same source.
FAQs
What is the difference between event.key and event.code for keyboard shortcuts?
event.key gives you the character a key produces once the keyboard layout and any held modifiers are taken into account, while event.code names the physical key position and stays the same whatever the layout. Match shortcuts on event.key so a 'k' binding means the letter printed on the cap, on every keyboard. Keep event.code for position-based input such as WASD in games. TanStack Hotkeys falls back to event.code only for letter and digit keys, and only when event.key hands back a special character instead, as macOS Option plus a letter does.
Should I use keydown, keyup, or keypress for keyboard shortcuts?
Use keydown. MDN marks keypress as deprecated, and it only fires for keys that produce a character, so it never reports Escape, arrow keys, or a modifier pressed alone. keydown fires for every key, exposes event.key and the modifier booleans, and is the event where preventDefault stops the browser's own action. keyup arrives after the browser has already acted on the keydown, so it cannot suppress a native shortcut or an inserted character.
Do keyboard shortcuts fire while a user is typing with an IME such as Japanese or Chinese input?
Yes. A document-level keydown listener still receives keystrokes while an IME is composing, so return early when event.isComposing is true. The flag stays true for every key event between the moment the IME opens a composition session and the moment it closes one, which is the whole window where your shortcuts should keep out of the way. The isTyping guard catches most cases because composition happens in an editable element, but isComposing adds a second check for custom text surfaces.