Handling keyboard shortcuts in JavaScript
Table of contents
I was recently reading the source code of mizu.js
1 and I liked the utility “function”2 they have to handle keyboard shortcuts/combinations. I decided to slightly modify it to have it as part of my JavaScript utility functions.
The function
This function returns another function itself. You pass in a string that represents a combination of key presses like Shift+Alt+C
, and it returns a function that will return true
if a KeyboardEvent matches that combination. I had to make some modifications to the original code. First, I converted it to JavaScript, because I don’t like having a build step, and I want to be able to copy-paste this code. I also changed the matcher on the event.key to match on event.code because I found event.key
was returning a string generated by the keyboard combination, not the actual key pressed.
According to MDN,
KeyboardEvent.code
ignores the user keyboard layout.
/**
* Creates a function to check if a keyboard event matches specified key combinations.
*
* @param {string} keys - A comma-separated string of key combinations (e.g., "Ctrl+B, Shift+Alt+C").
* @returns {function} - A function that takes a keyboard event and returns true if it matches any of the specified combinations.
*/
function keyboard(keys) {
const combinations = keys
.split(",")
.map((combination) =>
combination.split("+").map((key) => key.trim().toLowerCase())
);
return function (event) {
if (!/^key(?:down|press|up)$/.test(event.type)) {
return false;
}
return combinations.some((combination) => {
for (const key of combination) {
switch (key) {
case "alt":
if (!event.altKey) {
return false;
}
break;
case "ctrl":
if (!event.ctrlKey) {
return false;
}
break;
case "shift":
if (!event.shiftKey) {
return false;
}
break;
case "meta":
if (!event.metaKey) {
return false;
}
break;
case "space":
if (event.key !== " ") {
return false;
}
break;
case "key":
if (/^(?:alt|ctrl|shift|meta)$/i.test(event.key)) {
return false;
}
break;
default:
// Match `code` for physical key instead of `key`
if (event.code.toLowerCase() !== `key${key}`) {
return false;
}
}
}
return true;
});
};
}
Using it
You need to created matchers, and actions associated with them. In the examples below, I’m also checking the current active element with document.activeElement
. This is because I can imagine situations where I’ll want to apply some shortcuts only if certain elements are focused, or the event handler needs to retrieve content from the focused element.
// Define actions
const actions = [
{
matcher: keyboard("a"),
action: () => {
console.log("You pressed 'A'!");
},
},
{
matcher: keyboard("ctrl+b"),
action: () => {
console.log("You pressed 'Ctrl+B'!");
},
},
{
matcher: keyboard("shift+alt+c"),
action: () => {
console.log("You pressed 'Shift+Alt+C'!");
},
},
];
// Add event listener to the window to catch all keydown events
window.addEventListener("keydown", (event) => {
const focusedElement = document.activeElement;
for (const { matcher, action } of actions) {
if (matcher(event)) {
action();
console.log(`Focused element: ${focusedElement.tagName || "NONE"}`);
event.preventDefault();
break;
} else {
console.log("not matched");
}
}
});
Full example
Here’s a self-contained HTML file (partially generated with GPT-4o / Claude Sonnet 3.5) that showcases the functionality:
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keyboard Shortcuts</title>
<meta charset="UTF-8" />
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
input,
textarea,
button {
display: block;
margin: 10px 0;
padding: 10px;
font-size: 16px;
}
.log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
background: #f9f9f9;
max-height: 150px;
overflow-y: auto;
}
</style>
</head>
<body>
<h1>Keyboard Shortcuts Example</h1>
<p>Try pressing the following combinations:</p>
<ul>
<li><strong>A</strong>: Logs a message</li>
<li><strong>Ctrl + B</strong>: Logs a message</li>
<li><strong>Shift + Alt + C</strong>: Logs a message</li>
</ul>
<p>Type in the input field or textarea to see focused element behavior.</p>
<input type="text" placeholder="Type something..." />
<textarea placeholder="Type here..."></textarea>
<button>Click me!</button>
<div class="log" id="log"></div>
<script>
/**
* Creates a function to check if a keyboard event matches specified key combinations.
*
* @param {string} keys - A comma-separated string of key combinations (e.g., "Ctrl+B, Shift+Alt+C").
* @returns {function} - A function that takes a keyboard event and returns true if it matches any of the specified combinations.
*/
function keyboard(keys) {
const combinations = keys
.split(",")
.map((combination) =>
combination.split("+").map((key) => key.trim().toLowerCase())
);
return function (event) {
if (!/^key(?:down|press|up)$/.test(event.type)) {
return false;
}
return combinations.some((combination) => {
for (const key of combination) {
switch (key) {
case "alt":
if (!event.altKey) {
return false;
}
break;
case "ctrl":
if (!event.ctrlKey) {
return false;
}
break;
case "shift":
if (!event.shiftKey) {
return false;
}
break;
case "meta":
if (!event.metaKey) {
return false;
}
break;
case "space":
if (event.key !== " ") {
return false;
}
break;
case "key":
if (/^(?:alt|ctrl|shift|meta)$/i.test(event.key)) {
return false;
}
break;
default:
// Match `code` for physical key instead of `key`
if (event.code.toLowerCase() !== `key${key}`) {
return false;
}
}
}
return true;
});
};
}
// Define actions
const actions = [
{
matcher: keyboard("a"),
action: () => {
log("You pressed 'A'!");
},
},
{
matcher: keyboard("ctrl+b"),
action: () => {
log("You pressed 'Ctrl+B'!");
},
},
{
matcher: keyboard("shift+alt+c"),
action: () => {
log("You pressed 'Shift+Alt+C'!");
alert("You pressed 'Shift+Alt+C'!");
},
},
];
// Log messages to the UI
function log(message) {
const logElement = document.getElementById("log");
const entry = document.createElement("div");
entry.textContent = message;
logElement.appendChild(entry);
logElement.scrollTop = logElement.scrollHeight;
}
// Add event listener
window.addEventListener("keydown", (event) => {
const focusedElement = document.activeElement;
for (const { matcher, action } of actions) {
if (matcher(event)) {
action();
log(`Focused element: ${focusedElement.tagName || "NONE"}`);
event.preventDefault();
break;
} else {
console.log("not matched");
}
}
});
</script>
</body>
</html>