Keyboard Hotkeys for a Browser Soundboard: KeyboardEvent.code, Layout-Proof Labels and Not Hijacking the Search Box
A soundboard on a phone is a grid of buttons you tap. A soundboard on a laptop, during a stream or a call, is something you want to fire without looking: press Q, hear the buzzer. Streamers usually solve this in OBS with
A soundboard on a phone is a grid of buttons you tap. A soundboard on a laptop, during a stream or a call, is something you want to fire without looking: press Q, hear the buzzer. Streamers usually solve this in OBS with a hotkey per media source, but plenty of people just keep a browser tab open and wish the page itself listened to the keyboard.
Adding that to a web soundboard looks like a five-minute job. A keydown listener, a map from letters to buttons, done. Then someone on a French keyboard reports that the layout is scrambled, someone typing in the search box triggers six sounds, holding a key machine-guns a clip, and a screen reader user loses their own shortcuts.
This post is the hotkey layer I'd write today, in plain JavaScript so it drops into React, Svelte or a static page. The code is a generic pattern I tested with Node's test runner, not a copy of the production source of Meme Tap, the meme soundboard I built.
key or code?
KeyboardEvent gives you two names for the key that was pressed:
-
e.keyis the character the key produces on the user's layout:"q","a","Q"with Shift,"ΠΉ"on a Russian layout. -
e.codeis the physical position:"KeyQ"is the key to the right of Tab on every keyboard, whatever is printed on it.
For text shortcuts like "press ? for help", key is right, because you mean the character. For a soundboard grid, you mean a position. The top-left button should be the top-left key whether the user types on QWERTY, AZERTY or QWERTZ, so the grid shape on screen matches the shape under their fingers. That's code.
// hotkeys.js
export const SLOT_CODES = [
"Digit1","Digit2","Digit3","Digit4","Digit5","Digit6","Digit7","Digit8","Digit9","Digit0",
"KeyQ","KeyW","KeyE","KeyR","KeyT","KeyY","KeyU","KeyI","KeyO","KeyP",
"KeyA","KeyS","KeyD","KeyF","KeyG","KeyH","KeyJ","KeyK","KeyL",
];
const CODE_TO_SLOT = new Map(SLOT_CODES.map((code, i) => [code, i]));
Twenty-nine slots: the number row and two letter rows. I leave out the bottom row because Z, X and C sit right next to Ctrl and Cmd, and people hit them by accident while copying and pasting.
When not to play anything
Most of the bugs live here. A key press should not fire a sound when:
- A modifier is held. Ctrl+R reloads, Cmd+W closes the tab, Alt combinations are menu shortcuts on Windows and type characters on a Mac. Never swallow those.
-
The user is typing. Search inputs, comment boxes,
contenteditableregions. A range slider or a checkbox is fine, though, so check the input type rather than bailing on every<input>. -
An IME is composing. People typing Japanese, Chinese or Korean go through an input method editor. During composition,
e.isComposingis true, and some browsers reportkeyCode229 on the first event. Those key presses belong to the IME. -
Someone else already handled it. If a modal or a component called
preventDefault(), respect that.
export function isTypingTarget(el) {
if (!el || typeof el !== "object") return false;
if (el.isContentEditable) return true;
const tag = (el.tagName || "").toLowerCase();
if (tag === "textarea" || tag === "select") return true;
if (tag === "input") {
const nonText = ["button","checkbox","radio","range","color","file","submit","reset","image"];
return !nonText.includes((el.type || "text").toLowerCase());
}
return false;
}
export function slotForEvent(e) {
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return null;
if (e.ctrlKey || e.metaKey || e.altKey) return null;
if (isTypingTarget(e.target)) return null;
const slot = CODE_TO_SLOT.get(e.code);
return slot === undefined ? null : slot;
}
Shift is deliberately allowed. You can use it later as a "stop instead of play" modifier, and blocking it means a user with Caps Lock habits gets nothing.
keyCode is deprecated, but the 229 check is still the documented way to catch the first composition event in some browsers, so it stays.
Key repeat: one press, one sound
Hold a key down and the operating system starts sending repeated keydown events after a short delay. For a soundboard, that turns a hold into a stutter. You have two choices:
function onKeyDown(e) {
const slot = slotForEvent(e);
if (slot === null) return;
e.preventDefault(); // stop "/" opening find, Space scrolling, etc.
if (e.repeat) return; // ignore auto-repeat
playSlot(slot, { restart: true });
}
window.addEventListener("keydown", onKeyDown);
e.repeat is true for every repeated event, so a hold plays once. If you want "hold to loop", start playback on the first keydown and stop it on keyup for the same code instead.
Call preventDefault() only once you know the key maps to a slot. Calling it for every key breaks Tab navigation and browser shortcuts for everyone.
Show the right letter on each button
If a French user's KeyQ prints A, the label on the top-left button should say A. The Keyboard Map API can tell you, in Chromium-based browsers:
export function labelFor(code, layoutMap) {
const fromLayout = layoutMap?.get?.(code);
if (fromLayout) return fromLayout.toUpperCase();
if (code.startsWith("Digit")) return code.slice(5);
if (code.startsWith("Key")) return code.slice(3);
return code;
}
async function getLayoutMap() {
try {
return (await navigator.keyboard?.getLayoutMap?.()) ?? null;
} catch {
return null; // e.g. inside a cross-origin iframe
}
}
Firefox and Safari don't ship navigator.keyboard, so the fallback prints the US label. That's honest enough: the position is still correct, only the printed letter might differ. If that bothers you, update the label after the first real key press, since e.key tells you what that position produces for this user.
Make the shortcut discoverable and accessible
Hotkeys are invisible by default. A few small things fix that:
<button class="sound" data-slot="10" aria-keyshortcuts="Q">
<span class="name">Buzzer</span>
<kbd class="hint" aria-hidden="true">Q</kbd>
</button>
-
aria-keyshortcutstells assistive tech the button has a shortcut. Keep its value in sync with the label you computed. -
A visible
<kbd>hint, shown on devices with a fine pointer only, so phones don't get a grid of meaningless letters:
.hint { display: none; }
@media (hover: hover) and (pointer: fine) {
.hint { display: inline-block; }
}
-
A toggle. Single-key shortcuts are a known problem for speech-input users and for screen reader users whose own commands use single letters. WCAG 2.1 (success criterion 2.1.4) asks that character-key shortcuts can be turned off or remapped, or only work when a component has focus. A "Keyboard shortcuts" switch, stored in
localStorage, covers it:
const enabled = () => localStorage.getItem("hotkeys") !== "off";
function onKeyDown(e) {
if (!enabled()) return;
// ...as before
}
Another option is to listen on the grid container instead of window, so shortcuts only work once the grid has focus. That's the safest default for a page that is mostly a board.
Paging: more sounds than keys
A category page might have hundreds of buttons, and you have 29 keys. I map the keys to what is visible, not to the whole list. When the user scrolls or loads more, the slots shift to the first 29 buttons on screen. An IntersectionObserver that keeps an ordered list of visible buttons does this without measuring on every scroll:
const visible = new Set();
const io = new IntersectionObserver((entries) => {
for (const en of entries) {
en.isIntersecting ? visible.add(en.target) : visible.delete(en.target);
}
relabel();
}, { threshold: 0.6 });
document.querySelectorAll("button.sound").forEach((b) => io.observe(b));
function relabel() {
const ordered = [...document.querySelectorAll("button.sound")].filter((b) => visible.has(b));
ordered.forEach((b, i) => {
const code = SLOT_CODES[i];
b.dataset.slot = code ? i : "";
b.querySelector(".hint").textContent = code ? labelFor(code, layoutMap) : "";
code ? b.setAttribute("aria-keyshortcuts", labelFor(code, layoutMap)) : b.removeAttribute("aria-keyshortcuts");
});
}
Changing the labels as the page scrolls is fine for sighted keyboard users, who see the letters move. For favourites or a pinned "my board" view, fixed slots are kinder, because muscle memory starts to matter.
Testing it
None of this needs a browser to test. slotForEvent takes plain objects, so Node's built-in runner covers the cases that bit me:
import { test } from "node:test";
import assert from "node:assert";
import { slotForEvent } from "./hotkeys.js";
const ev = (o) => ({ code: "KeyQ", target: { tagName: "BODY" }, ...o });
test("AZERTY 'a' on KeyQ is still slot 10", () =>
assert.equal(slotForEvent(ev({ key: "a" })), 10));
test("ignores IME composition", () =>
assert.equal(slotForEvent(ev({ isComposing: true })), null));
test("ignores search box, allows range slider", () => {
assert.equal(slotForEvent(ev({ target: { tagName: "INPUT", type: "search" } })), null);
assert.equal(slotForEvent(ev({ target: { tagName: "INPUT", type: "range" } })), 10);
});
test("leaves Ctrl shortcuts alone", () =>
assert.equal(slotForEvent(ev({ ctrlKey: true })), null));
Where a page hotkey stops being enough
Page hotkeys only work while the tab has focus. The moment you click into a game or OBS, the browser stops receiving key presses, and no web API changes that for good reasons. If you need sounds while another app is focused, download the clips and load them into the tool that owns global hotkeys. The Meme Tap guide to a podcast and live stream soundboard in OBS walks through that setup, with a media source per clip and a hotkey on each.
For everything else, a focused tab with good hotkeys is surprisingly pleasant. Physical positions with code, no firing while typing or composing, one sound per press, labels from the real layout, and a switch to turn it all off.
I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built Meme Tap, a free meme soundboard and meme maker that runs in the browser.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.