
Security News
Socket Releases Free Certified Patches for Critical vm2 Sandbox Escape
A critical vm2 sandbox escape can allow untrusted JavaScript to break isolation and execute commands on the host Node.js process.
matches-hotkeys
Advanced tools
Parse keyboard shortcuts and match them against KeyboardEvent objects.
This library provides functions to:
"ctrl+a", "mod+shift+p") into normalized representationsKeyboardEvent objects against parsed hotkey specificationsmod resolves to cmd on macOS, ctrl elsewhere)"0" matches both top-row and numpad)What this library does not do:
These concerns are left to the application layer.
npm install matches-hotkeys
TypeScript types are included. ES Module, CommonJS, and IIFE builds are provided.
import { matchesHotkeys } from "matches-hotkeys";
// Define shortcuts
const SAVE_SHORTCUT = [{ combination: "mod+s" }]; // cmd+s on macOS, ctrl+s elsewhere
// Check if event matches
window.addEventListener("keydown", (event) => {
if (matchesHotkeys(SAVE_SHORTCUT, event)) {
event.preventDefault();
saveDocument();
}
});
import { matchesHotkeys, parseCombination } from "matches-hotkeys";
For direct browser usage via <script> tag, IIFE builds are available. The global variable is MatchesHotkeys.
<script src="https://cdn.jsdelivr.net/npm/matches-hotkeys@<version>/dist/iife/index.js"></script>
<script>
const { matchesHotkeys } = MatchesHotkeys;
// Use matchesHotkeys...
</script>
matchesHotkeys(hotkeys, event, options?)Tests if a KeyboardEvent matches any of the provided hotkey specifications.
Parameters:
hotkeys: Array of { combination, options? } objectsevent: A KeyboardEvent instanceoptions: Optional { comparator? } configurationReturns: boolean - true if any hotkey matches the event
Example:
const hotkeys = [{ combination: "ctrl+s" }, { combination: "cmd+s" }];
matchesHotkeys(hotkeys, event); // true if event is Ctrl+S or Cmd+S
parseCombination(combination, options?)Parses a hotkey combination string or array into normalized representations.
Parameters:
combination: String ("ctrl+a") or array (["ctrl", "a"])options: Optional configuration
splitBy: Separator character (default: "+").
Change this when you need to use "+" as the actual key in your shortcut (e.g., "ctrl-+" with splitBy: "-")
trim: Whether to trim whitespace from each token after splitting (default: true when combination is a string, false when it's an array).
When true, whitespace around tokens is removed, making empty spaces in string combinations become empty tokens (which are invalid). Set to false for string combinations to preserve the space character as a valid key (e.g., "ctrl+ " with trim: false matches the space key)
allowCodeAsModifier: Allow physical key codes like "ControlLeft" or "ShiftRight" as modifiers (default: true).
When true, allows both "ctrl+a" and "ControlLeft+a" (both produce the same result with ctrlKey: true, since browsers cannot distinguish left/right modifiers at runtime). When false, only logical modifier names like "ctrl" are accepted in modifier positions, rejecting "ControlLeft+a" as invalid (but "ControlLeft" alone as a main key is still valid)
inferShift: Automatically infer shiftKey: true for shift-derived keys (default: false).
When true, keys that can only be produced with Shift (e.g., "+" from Equal key, "!" from Digit1) automatically get shiftKey: true for physical keys that require Shift. When false, shiftKey is only set based on explicitly provided modifiers. See Shift-Derived Keys for details.
Returns: ParsedCombination[] - Array of parsed variants (empty if invalid)
Examples:
// Basic usage
parseCombination("ctrl+a");
// [{ code: "KeyA", key: "a", keyCode: 65, which: 65, ctrlKey: true, metaKey: false, shiftKey: false, altKey: false }]
// Ambiguous keys return multiple variants
parseCombination("0");
// [
// { code: "Digit0", key: "0", keyCode: 48, which: 48, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false },
// { code: "Numpad0", key: "0", keyCode: 96, which: 96, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false }
// ]
// Key aliases work for shifted keys (e.g., "plus" β "+")
parseCombination("ctrl+plus"); // "plus" is an alias for "+"
// [
// { code: "NumpadAdd", key: "+", keyCode: 107, which: 107, ctrlKey: true, shiftKey: false, ... }, // Numpad
// { code: "Equal", key: "+", keyCode: 187, which: 187, ctrlKey: true, shiftKey: false, ... } // Top-row
// ]
// Option: splitBy - Use different separator for literal "+" key
parseCombination("ctrl-+", { splitBy: "-" }); // Direct "+" character as key
// [
// { code: "NumpadAdd", key: "+", keyCode: 107, which: 107, ctrlKey: true, shiftKey: false, ... },
// { code: "Equal", key: "+", keyCode: 187, which: 187, ctrlKey: true, shiftKey: false, ... }
// ]
// Option: trim - Preserve whitespace to match space key
parseCombination("ctrl+ "); // Default trim removes space, " " becomes ""
// [] (empty - invalid because last token is empty)
parseCombination("ctrl+ ", { trim: false }); // Space key preserved
// [{ code: "Space", key: " ", keyCode: 32, which: 32, ctrlKey: true, shiftKey: false, ... }]
// Option: allowCodeAsModifier - Enforce logical modifiers only
parseCombination("ControlLeft+a"); // Physical code as modifier (allowed by default)
// [{ code: "KeyA", key: "a", keyCode: 65, which: 65, ctrlKey: true, shiftKey: false, ... }]
parseCombination("ControlLeft+a", { allowCodeAsModifier: false }); // Reject physical codes
// [] (empty - invalid because "ControlLeft" is not a logical modifier)
parseCombination("ctrl+a", { allowCodeAsModifier: false }); // Logical modifier OK
// [{ code: "KeyA", key: "a", keyCode: 65, which: 65, ctrlKey: true, shiftKey: false, ... }]
// Option: inferShift - Automatically infer shift for shift-derived keys
parseCombination("ctrl+plus"); // Default: inferShift=false, no automatic inference
// [
// { code: "NumpadAdd", key: "+", keyCode: 107, which: 107, ctrlKey: true, shiftKey: false, ... },
// { code: "Equal", key: "+", keyCode: 187, which: 187, ctrlKey: true, shiftKey: false, ... }
// ]
parseCombination("ctrl+plus", { inferShift: true }); // Automatic shift inference enabled
// [
// { code: "NumpadAdd", key: "+", keyCode: 107, which: 107, ctrlKey: true, shiftKey: false, ... }, // Numpad doesn't need Shift
// { code: "Equal", key: "+", keyCode: 187, which: 187, ctrlKey: true, shiftKey: true, ... } // Shift inferred for Equal
// ]
resolveKey(token)Resolves a single key token into standardized key information. Used internally by parseCombination.
Parameters:
token: A single key string (case-insensitive)Returns: ResolvedKey[] - Array of possible key resolutions
Resolution behavior:
"a", "Escape") return one result"0", "+") return multiple results for different physical keys"ctrl", "shift") return both left and right variants"ControlLeft") return only that variantkeyCode: -1 and which: -1Examples:
resolveKey("a"); // [{ key: "a", code: "KeyA", keyCode: 65, which: 65 }]
resolveKey("0"); // Ambiguous - returns both top-row and numpad
// [
// { key: "0", code: "Digit0", keyCode: 48, which: 48 },
// { key: "0", code: "Numpad0", keyCode: 96, which: 96 }
// ]
resolveKey("ctrl"); // Generic modifier - returns both variants
// [
// { key: "Control", code: "ControlLeft", keyCode: 17, which: 17 },
// { key: "Control", code: "ControlRight", keyCode: 17, which: 17 }
// ]
resolveKey("ControlLeft"); // Specific modifier - returns only left variant
// [{ key: "Control", code: "ControlLeft", keyCode: 17, which: 17 }]
resolveKey("unknown"); // Unknown key - returns fallback
// [{ key: "unknown", code: "unknown", keyCode: -1, which: -1 }]
import { matchesHotkeys } from "matches-hotkeys";
window.addEventListener("keydown", (event) => {
// Save
if (matchesHotkeys([{ combination: "mod+s" }], event)) {
event.preventDefault();
save();
}
// Copy
if (matchesHotkeys([{ combination: "mod+c" }], event)) {
copy();
}
// Open command palette
if (matchesHotkeys([{ combination: "mod+shift+p" }], event)) {
event.preventDefault();
openCommandPalette();
}
});
const shortcuts = [
{ combination: "mod+s", action: save },
{ combination: "mod+shift+s", action: saveAs },
{ combination: "mod+o", action: open },
{ combination: "mod+w", action: close },
];
window.addEventListener("keydown", (event) => {
for (const { combination, action } of shortcuts) {
if (matchesHotkeys([{ combination }], event)) {
event.preventDefault();
action();
break;
}
}
});
const NAVIGATION = [
{ combination: "arrowup" },
{ combination: "arrowdown" },
{ combination: "arrowleft" },
{ combination: "arrowright" },
];
window.addEventListener("keydown", (event) => {
if (matchesHotkeys(NAVIGATION, event)) {
event.preventDefault();
navigate(event.key);
}
});
By default, a hotkey matches if any of key, code, keyCode, or which match AND all modifier flags are identical. You can customize this by composing your own comparators or using the exported ones.
import { eq, and, or } from "matches-hotkeys";
// eq(...fields) - Creates a comparator that checks equality for specific fields
const checkKey = eq("key");
const checkModifiers = eq("altKey", "ctrlKey", "metaKey", "shiftKey");
// and(...comparators) - All comparators must match
const strictMatch = and(checkKey, checkModifiers);
// or(...comparators) - Any comparator can match
const flexibleMatch = or(eq("key"), eq("code"));
The library exports several pre-built comparators you can use directly or combine:
import {
DEFAULT_COMPARATOR, // Matches by (key OR code OR keyCode OR which) + all modifiers
MODIFIERS_COMPARATOR, // Only checks modifier flags match
COMPARE_BY_KEY, // Matches by key (case-insensitive) + all modifiers
COMPARE_BY_CODE, // Matches by code + all modifiers
COMPARE_BY_KEY_CODE, // Matches by keyCode + all modifiers
COMPARE_BY_WHICH, // Matches by which + all modifiers
} from "matches-hotkeys";
Note:
COMPARE_BY_KEYuses case-insensitive matching for thekeyfield. This ensures that shortcuts like"a"correctly matchShift+KeyAevents (whereevent.keyis"A"), avoiding a common mismatch when the Shift modifier changes the case of letter keys.
Compose a custom comparator from primitives:
import { matchesHotkeys, eq, and, or } from "matches-hotkeys";
const IGNORE_SHIFT = or(
and(eq("key", "altKey", "ctrlKey", "metaKey")),
and(eq("code", "altKey", "ctrlKey", "metaKey")),
);
// Matches both "a" and "Shift+a"
if (
matchesHotkeys([{ combination: "a" }], event, { comparator: IGNORE_SHIFT })
) {
handleKey();
}
import { matchesHotkeys, COMPARE_BY_CODE } from "matches-hotkeys";
// Only match by physical key position, ignore key value
if (
matchesHotkeys([{ combination: "a" }], event, { comparator: COMPARE_BY_CODE })
) {
handleAction();
}
import {
matchesHotkeys,
or,
COMPARE_BY_KEY,
COMPARE_BY_CODE,
} from "matches-hotkeys";
// Match by either key or code (but not keyCode/which)
const KEY_OR_CODE = or(COMPARE_BY_KEY, COMPARE_BY_CODE);
if (
matchesHotkeys([{ combination: "a" }], event, { comparator: KEY_OR_CODE })
) {
handleAction();
}
You can also write completely custom logic:
import type { Comparator } from "matches-hotkeys";
// Custom: Ignore Shift modifier but check the key and other modifiers
const IGNORE_SHIFT: Comparator = (parsed, event) => {
return (
parsed.key === event.key &&
parsed.ctrlKey === event.ctrlKey &&
parsed.metaKey === event.metaKey &&
parsed.altKey === event.altKey
// Note: shiftKey is intentionally not checked
);
};
// Now "a" matches both plain "a" and "Shift+a"
if (
matchesHotkeys([{ combination: "a" }], event, { comparator: IGNORE_SHIFT })
) {
handleKey();
}
The parser relies on the W3C keyboard model exposed by KeyboardEvent and encoded in src/consts.ts:
key β The logical character or action produced by the key (e.g., "a", "Enter", "+"). We store this in KEY_DEFINITIONS[code].key and match it against event.key.code β The physical key location (e.g., "KeyA", "ShiftLeft", "NumpadAdd"). This stays the same regardless of keyboard layout and is matched against event.code.keyCode / which β Legacy numeric codes kept for compatibility. We surface the numeric value from KEY_DEFINITIONS and mirror it onto which, just like the browser does.Every ParsedCombination exposes all three so callers can pick the level of precision they need.
To keep authoring ergonomic we pre-compute several alias maps when resolving tokens:
KEY_ALIASES) let you write friendly names for logical keys. Examples: "esc" β "Escape", "plus" β "+", "space" β " ".CODE_ALIAS_MAP) cover physical key nicknames such as "lshift" β "ShiftLeft" or "prtsc" β "PrintScreen".SHIFT_KEY_MAPPINGS) synthesize characters that only appear when Shift is held. For instance, "Equal" + Shift β "+", so resolving "plus" yields both { code: "NumpadAdd", key: "+" } and { code: "Equal", key: "+" }.Aliases are applied in this order inside resolveKey: exact code β code alias β key value β key alias β fallback. This ensures that precise tokens stay precise while still supporting more human-readable inputs.
Combinations can be declared as strings ("ctrl+shift+p") or arrays (["ctrl", "shift", "p"]). The parser normalizes them as follows:
const stringForm = "ctrl+shift+p";
const arrayForm: string[] = ["ctrl", "shift", "p"]; // Equivalent representation
splitBy (default "+") and lower-cased via preMap.ctrl, controlmeta, cmd, command, win, windowsshiftalt, optionmod token resolves to cmd on macOS and ctrl elsewhere (see preMap).Modifier side note. Browser events only expose boolean modifier flags (metaKey, ctrlKey, shiftKey, altKey). When a shortcut includes a modifier plus another key (e.g., ctrl+a), the resulting KeyboardEvent cannot distinguish between left and right modifier keys. Consequently, combinations like "ControlLeft+a" and "ControlRight+a" are both parsed to produce the same result: { ctrlKey: true, ... }. The physical code distinction is lost because browsers don't provide separate flags for ctrlLeftKey vs ctrlRightKey.
Invalid sequences (missing main key, duplicate modifiers, empty segments) produce an empty array of parsed combinations.
parseCombination processes each token through resolveKey to obtain one or more ResolvedKey objects, then combines modifiers with main keys:
splitBy, trim (if enabled), and convert to lowercase.metaKey, ctrlKey, etc.), respecting allowCodeAsModifier.Digit0 and Numpad0 for "0").ParsedCombination for each main key variant, each including key/code/keyCode metadata plus all modifier flags.matchesHotkeys then compares these parsed combinations against the actual KeyboardEvent using the selected comparator.
Some keys produce different characters when Shift is held (e.g., pressing Equal produces "=", but Shift+Equal produces "+"). The library handles these through the SHIFT_KEY_MAPPINGS constant, which maps base keys to their shifted characters.
When you reference a shifted character (e.g., "+", "!", "@"), the library will resolve it to the appropriate physical key. For example, "+" resolves to both NumpadAdd (which produces "+" without Shift) and Equal (which produces "+" with Shift).
The following keys have shifted character mappings:
+ (from Equal), ! (from Digit1), @ (from Digit2), # (from Digit3)$ (from Digit4), % (from Digit5), ^ (from Digit6), & (from Digit7)* (from Digit8), ( (from Digit9), ) (from Digit0)_ (from Minus), ~ (from Backquote){ (from BracketLeft), } (from BracketRight), | (from Backslash): (from Semicolon), " (from Quote)< (from Comma), > (from Period), ? (from Slash)By default (inferShift: false), the library does not automatically infer shift modifiers. You must explicitly include shift in your combination to match shifted characters.
However, you can enable automatic shift inference using the inferShift: true option. When enabled, keys that can only be produced with Shift automatically get shiftKey: true for physical keys that require it:
// Default behavior (inferShift: false)
parseCombination("ctrl+plus");
// [
// { code: "NumpadAdd", key: "+", ctrlKey: true, shiftKey: false, ... },
// { code: "Equal", key: "+", ctrlKey: true, shiftKey: false, ... }
// ]
// With inferShift: true
parseCombination("ctrl+plus", { inferShift: true });
// [
// { code: "NumpadAdd", key: "+", ctrlKey: true, shiftKey: false, ... }, // Numpad doesn't need Shift
// { code: "Equal", key: "+", ctrlKey: true, shiftKey: true, ... } // Shift automatically inferred
// ]
Why use automatic inference?
Without automatic shift inference, the Equal variant would have shiftKey: false, which may not match real keyboard events where the user must hold Shift to produce "+" from the Equal key. However, the library's default comparator uses OR logic (matching on key OR code OR keyCode OR which), so matching still works correctly in most cases even without inference.
Automatic inference is useful when you want strict modifier matching or when using custom comparators that require exact modifier flag matches.
You can always explicitly include shift in your combination regardless of the inferShift setting:
// Explicit shift always sets shiftKey: true
parseCombination("shift+plus");
// [
// { code: "NumpadAdd", key: "+", shiftKey: true }, // Matches Shift+NumpadAdd
// { code: "Equal", key: "+", shiftKey: true } // Matches Shift+Equal (produces "+")
// ]
// Without explicit shift and inferShift=false (default)
parseCombination("plus");
// [
// { code: "NumpadAdd", key: "+", shiftKey: false },
// { code: "Equal", key: "+", shiftKey: false }
// ]
// Base Equal key without shift (produces "=")
parseCombination("ctrl+=");
// [{ code: "Equal", key: "=", ctrlKey: true, shiftKey: false }]
// Only numpad plus (no shift)
parseCombination("ctrl+numpadadd");
// [{ code: "NumpadAdd", key: "+", ctrlKey: true, shiftKey: false }]
Some key inputs map to multiple physical keys. The parser returns all possibilities:
parseCombination("0");
// Returns both:
// 1. { code: "Digit0", ... } // Top row
// 2. { code: "Numpad0", ... } // Numpad
parseCombination("ctrl");
// Returns both:
// 1. { code: "ControlLeft", ctrlKey: true, ... }
// 2. { code: "ControlRight", ctrlKey: true, ... }
matchesHotkeys tests all variants and returns true if any matches.
Unknown key names create fallback objects with -1 for numeric fields:
resolveKey("unknownkey");
// [{ key: "unknownkey", code: "unknownkey", keyCode: -1, which: -1 }]
parseCombination("ctrl+unknownkey");
// [{ key: "unknownkey", code: "unknownkey", keyCode: -1, which: -1, ctrlKey: true, ... }]
This preserves type consistency and allows detection of unknown keys. Using -1 (instead of undefined) keeps the shape consistent and makes the data JSON-serializable.
interface ParsedCombination {
code: string; // Physical key code (e.g., "KeyA")
key: string; // Logical key value (e.g., "a")
keyCode: number; // Legacy numeric code (or -1)
which: number; // Alias of keyCode
metaKey: boolean; // Cmd/Win modifier
ctrlKey: boolean; // Control modifier
shiftKey: boolean; // Shift modifier
altKey: boolean; // Alt/Option modifier
}
All fields are present to match KeyboardEvent shape and support serialization.
g g (Vim-style), implement your own state machine.This library follows W3C specifications:
MIT
FAQs
Hotkey matching utilities for keyboard shortcuts
We found that matches-hotkeys demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago.Β It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
A critical vm2 sandbox escape can allow untrusted JavaScript to break isolation and execute commands on the host Node.js process.

Research
Five malicious NuGet packages impersonate Chinese .NET libraries to deploy a stealer targeting browser credentials, crypto wallets, SSH keys, and local files.

Security News
pnpm 11 turns on a 1-day Minimum Release Age and blocks exotic subdeps by default, adding safeguards against fast-moving supply chain attacks.