Add keyboard shortcuts to a page with JavaScript

One keydown listener on the document is enough for a whole set of shortcuts. The work is in the checks: which key, which modifier, and whether the user is typing.

A keyboard shortcut in JavaScript is a keydown listener on document that checks which key was pressed and runs a function. For example, e.key === 'd' toggles dark mode.

Add a check for text fields and a Cmd or Ctrl check, and the basics are done.

Try it first. Click inside the example so it has keyboard focus, then press the keys.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keyboard shortcuts</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  body.dark { background: #1d2330; color: #f4f5f7; }
  kbd { font: 600 13px ui-monospace, Consolas, monospace; padding: 1px 6px; border: 1px solid #b8bec9; border-bottom-width: 2px; border-radius: 5px; background: #fff; color: #1d2330; }
  input { width: 100%; max-width: 320px; box-sizing: border-box; font: inherit; padding: 8px 10px; margin: 10px 0; }
  button { font: inherit; padding: 7px 12px; margin: 4px 6px 0 0; }
  #out { margin-top: 12px; min-height: 1.4em; font-weight: 600; }
</style>
</head>
<body>
<p>Click here first, then press <kbd>D</kbd> for dark mode or <kbd id="mod">Ctrl</kbd>+<kbd>S</kbd> to save.</p>
<input placeholder="Type a d here: no dark mode">
<div>
  <button id="darkBtn">Dark mode (D)</button>
  <button id="saveBtn">Save</button>
</div>
<div id="out"></div>

<script>
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
  document.getElementById('mod').textContent = isMac ? 'Cmd' : 'Ctrl';
  const out = document.getElementById('out');

  function toggleDark() { document.body.classList.toggle('dark'); out.textContent = 'Dark mode toggled'; }
  function save() { out.textContent = 'Saved at ' + new Date().toLocaleTimeString(); }

  document.addEventListener('keydown', (e) => {
    // Ctrl+S on Windows and Linux, Cmd+S on a Mac
    const mod = isMac ? e.metaKey : e.ctrlKey;
    if (mod && e.key.toLowerCase() === 's') {
      e.preventDefault();  // stop the browser's own "Save page" dialog
      save();
      return;
    }
    // single-key shortcuts must not fire while the user is typing
    if (e.target.closest('input, textarea, select, [contenteditable]')) return;
    if (!mod && !e.altKey && e.key.toLowerCase() === 'd') toggleDark();
  });

  // buttons do the same thing, for phones and mouse users
  document.getElementById('darkBtn').addEventListener('click', toggleDark);
  document.getElementById('saveBtn').addEventListener('click', save);
</script>
</body>
</html>
D toggles dark mode, Ctrl+S (Cmd+S on a Mac) saves. Typing a d in the box does not trigger anything.

The whole listener is short:

document.addEventListener('keydown', (e) => {
  const mod = isMac ? e.metaKey : e.ctrlKey;
  if (mod && e.key.toLowerCase() === 's') {
    e.preventDefault();  // no "Save page" dialog
    save();
    return;
  }
  if (e.target.closest('input, textarea, select, [contenteditable]')) return;
  if (!mod && !e.altKey && e.key.toLowerCase() === 'd') toggleDark();
});

Why keydown? It fires before the browser acts on the key, so preventDefault() can still stop the default action. It also fires for keys that type nothing, such as Escape and the arrows.

The older keypress event is deprecated. For the listener basics, see addEventListener.

e.key or e.code: which one to compare

Every keyboard event carries two names for the key. e.key is the character the key produces. e.code is the physical key, named after its position on a US keyboard.

The same physical key reports a different e.key on a French layout, but the same e.code.
The same physical key reports a different e.key on a French layout, but the same e.code.

Press a few keys here and watch both values. Try Shift, and try holding a key down.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>e.key vs e.code</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  p { margin: 0 0 10px; }
  input { width: 100%; max-width: 340px; box-sizing: border-box; font: inherit; padding: 8px 10px; }
  table { border-collapse: collapse; margin-top: 12px; width: 100%; max-width: 360px; background: #fff; }
  td { border: 1px solid #dde1e7; padding: 6px 10px; font-size: 14px; }
  td:first-child { color: #5b6472; width: 42%; }
  td:last-child { font: 600 14px ui-monospace, Consolas, monospace; }
  #combo { color: #0f5132; }
</style>
</head>
<body>
<p>Click the box and press any key, with or without Shift, Ctrl, Alt or Cmd.</p>
<input id="box" placeholder="Press keys here" autocomplete="off">
<table>
  <tr><td>e.key</td><td id="key">-</td></tr>
  <tr><td>e.code</td><td id="code">-</td></tr>
  <tr><td>Modifiers</td><td id="mods">-</td></tr>
  <tr><td>e.repeat</td><td id="repeat">-</td></tr>
  <tr><td>Shortcut name</td><td id="combo">-</td></tr>
</table>

<script>
  const $ = (id) => document.getElementById(id);

  // Turn an event into a name such as "Ctrl+Shift+K"
  function comboName(e) {
    const parts = [];
    if (e.ctrlKey) parts.push('Ctrl');
    if (e.metaKey) parts.push('Meta');
    if (e.altKey) parts.push('Alt');
    if (e.shiftKey) parts.push('Shift');
    const k = e.key.length === 1 ? e.key.toUpperCase() : e.key;
    if (!['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) parts.push(k);
    return parts.join('+');
  }

  $('box').addEventListener('keydown', (e) => {
    $('key').textContent = JSON.stringify(e.key);
    $('code').textContent = JSON.stringify(e.code);
    $('mods').textContent = ['ctrlKey', 'metaKey', 'altKey', 'shiftKey']
      .filter((m) => e[m]).join(' ') || 'none';
    $('repeat').textContent = e.repeat;
    $('combo').textContent = comboName(e);
  });
</script>
</body>
</html>
Each key press shows e.key, e.code, the modifiers held and the shortcut name built from them.
e.key e.code
What it is The character typed, or a name such as "Enter" The physical key, such as "KeyK"
Follows the keyboard layout Yes No
Changes with Shift or Caps Lock Yes: "k" becomes "K" No
Use it for Ctrl+K, ?, / W, A, S, D in a game

Two habits avoid surprises. Lowercase e.key before comparing, because Caps Lock turns "k" into "K". And compare the final character for shifted symbols: on a US keyboard, Shift+/ arrives as e.key === '?', which also works on layouts where ? sits somewhere else.

e.repeat is true while a key is held down and the operating system repeats it. Return early on repeats for actions that should run once per press.

Modifier keys, and Cmd on a Mac

Each event has four booleans: ctrlKey, shiftKey, altKey and metaKey. On a Mac, the Command key sets metaKey. Mac apps use Command where Windows and Linux apps use Ctrl, so Ctrl+K on Windows should be Cmd+K on a Mac.

Read metaKey on a Mac and ctrlKey elsewhere, then use one variable everywhere.
Read metaKey on a Mac and ctrlKey elsewhere, then use one variable everywhere.
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;

MDN lists navigator.platform as deprecated, but browsers still provide it, and it is a common way to spot a Mac.

Show the right label too: the examples on this page write Cmd or Ctrl in their hints based on the same check. For drawing the keys themselves, see the kbd tag.

Check the modifiers you do not want as well. Without !e.altKey, a plain d shortcut also fires for Alt+D.

Do not fire while the user is typing

Key events bubble. A d typed into a search box reaches the document listener just like a d pressed anywhere else. Without a guard, typing a word that contains your shortcut letter triggers it.

The order of checks inside one keydown listener.
The order of checks inside one keydown listener.

The guard looks at where the event started:

if (e.target.closest('input, textarea, select, [contenteditable]')) return;

Put combinations that use Cmd or Ctrl before this line. Then Cmd+K still opens the palette from inside a text box, while single keys type normally.

Also skip events with e.isComposing set. People typing Japanese, Chinese and other languages through an input method press keys, including Enter, to build each word. Those presses are not shortcuts.

preventDefault and the browser's own shortcuts

Some combinations already do something. Ctrl+S opens the save dialog, Ctrl+P prints, and Ctrl+K in Chrome and Firefox moves focus to the search or address bar. Calling e.preventDefault() in your keydown listener stops that default action, so only your code runs.

Call it only when you handle the key. A listener that blocks every key breaks typing, scrolling with the arrows and Tab navigation.

Some combinations never reach the page at all. The browser or the operating system handles them first. In Chrome, Ctrl+N, Ctrl+T and Ctrl+W are examples: they open a window, open a tab or close the tab, and no preventDefault() can stop them.

A command palette on Ctrl/Cmd+K

A command palette is a search box of actions. The user presses Ctrl+K (Cmd+K on a Mac), types part of a name and presses Enter. This finished example has a palette, single-key shortcuts and a help dialog, all driven by one list.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Command palette</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  body.dark { background: #1d2330; color: #f4f5f7; }
  kbd { font: 600 12px ui-monospace, Consolas, monospace; padding: 1px 6px; border: 1px solid #b8bec9; border-bottom-width: 2px; border-radius: 5px; background: #fff; color: #1d2330; }
  .bar button { font: inherit; padding: 7px 12px; margin: 0 6px 6px 0; }
  #notes { list-style: none; padding: 0; margin: 10px 0 0; }
  #notes li { background: #fff; color: #1d2330; border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; box-shadow: 0 2px 6px rgba(0,0,0,.08); }
  dialog { width: min(380px, calc(100% - 32px)); box-sizing: border-box; border: 0; border-radius: 12px; padding: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.3); }
  dialog::backdrop { background: rgba(20, 24, 32, .45); }
  #q { width: 100%; box-sizing: border-box; font: inherit; padding: 9px 10px; border: 1px solid #cfd4dc; border-radius: 8px; }
  #list { list-style: none; padding: 0; margin: 8px 0 0; }
  #list li { display: flex; justify-content: space-between; padding: 8px 10px; border-radius: 7px; cursor: pointer; }
  #list li.active { background: #e3edff; }
  #help h2 { font-size: 16px; margin: 2px 0 10px; }
  #help table { width: 100%; border-collapse: collapse; font-size: 14px; }
  #help td { padding: 6px 4px; border-top: 1px solid #eceef2; }
  #help td:last-child { text-align: right; white-space: nowrap; }
  #help button { font: inherit; margin-top: 10px; padding: 6px 12px; }
</style>
</head>
<body>
<p>Click here, then press <kbd class="mod">Ctrl</kbd>+<kbd>K</kbd> for commands or <kbd>?</kbd> for help.</p>
<div class="bar">
  <button id="openPalette">Commands</button>
  <button id="openHelp">Shortcuts</button>
</div>
<ul id="notes"></ul>

<dialog id="palette" aria-label="Command palette">
  <input id="q" placeholder="Type a command" autocomplete="off">
  <ul id="list"></ul>
</dialog>

<dialog id="help" aria-labelledby="helpTitle">
  <h2 id="helpTitle">Keyboard shortcuts</h2>
  <table id="helpRows"></table>
  <form method="dialog"><button>Close</button></form>
</dialog>

<script>
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
  const modName = isMac ? 'Cmd' : 'Ctrl';
  document.querySelectorAll('.mod').forEach((k) => (k.textContent = modName));
  const $ = (id) => document.getElementById(id);

  // One list drives the shortcuts, the palette and the help dialog
  const commands = [
    { name: 'Add a note', key: 'N', run: () => addNote() },
    { name: 'Toggle dark mode', key: 'D', run: () => document.body.classList.toggle('dark') },
    { name: 'Clear notes', key: '', run: () => ($('notes').innerHTML = '') },
    { name: 'Show keyboard shortcuts', key: '?', run: () => $('help').showModal() },
  ];

  let count = 0;
  function addNote() {
    const li = document.createElement('li');
    li.textContent = 'Note ' + ++count;
    $('notes').append(li);
  }

  // Global shortcuts
  document.addEventListener('keydown', (e) => {
    if (e.isComposing) return;  // the user is typing with an input method
    const mod = isMac ? e.metaKey : e.ctrlKey;
    if (mod && e.key.toLowerCase() === 'k') {
      e.preventDefault();  // the browser also uses Ctrl+K
      openPalette();
      return;
    }
    if (e.target.closest('input, textarea, select, [contenteditable]')) return;
    if (document.querySelector('dialog[open]')) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;  // leave browser shortcuts alone
    const cmd = commands.find((c) => c.key && c.key === e.key.toUpperCase());
    if (cmd) { e.preventDefault(); cmd.run(); }
  });

  // Command palette
  let matches = [], active = 0;
  function render() {
    const text = $('q').value.trim().toLowerCase();
    matches = commands.filter((c) => c.name.toLowerCase().includes(text));
    active = Math.min(active, Math.max(matches.length - 1, 0));
    $('list').innerHTML = '';
    matches.forEach((c, i) => {
      const li = document.createElement('li');
      li.className = i === active ? 'active' : '';
      li.append(Object.assign(document.createElement('span'), { textContent: c.name }));
      if (c.key) li.append(Object.assign(document.createElement('kbd'), { textContent: c.key }));
      li.addEventListener('click', () => runCommand(c));
      $('list').append(li);
    });
  }
  function openPalette() {
    if ($('help').open) $('help').close();
    if (!$('palette').open) $('palette').showModal();
    $('q').value = ''; active = 0; render(); $('q').focus();
  }
  function runCommand(c) {
    $('palette').close();
    c.run();
  }
  $('q').addEventListener('input', () => { active = 0; render(); });
  $('q').addEventListener('keydown', (e) => {
    if (e.isComposing) return;
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();  // keep the caret where it is
      const step = e.key === 'ArrowDown' ? 1 : -1;
      active = (active + step + matches.length) % matches.length;
      render();
    } else if (e.key === 'Enter' && matches[active]) {
      runCommand(matches[active]);
    }
  });

  // Help dialog, built from the same list
  const rows = [[modName + '+K', 'Open commands'], ['Esc', 'Close a dialog']]
    .concat(commands.filter((c) => c.key).map((c) => [c.key, c.name]));
  rows.forEach(([keys, name]) => {
    const tr = $('helpRows').insertRow();
    tr.insertCell().textContent = name;
    const cell = tr.insertCell();
    keys.split('+').forEach((k, i) => {
      if (i) cell.append('+');
      cell.append(Object.assign(document.createElement('kbd'), { textContent: k }));
    });
  });

  $('openPalette').addEventListener('click', openPalette);
  $('openHelp').addEventListener('click', () => $('help').showModal());
</script>
</body>
</html>
Ctrl/Cmd+K opens the palette. Arrow keys move, Enter runs, Esc closes. N adds a note, D toggles dark mode, ? shows every shortcut. On a phone, tap the buttons.

How it fits together:

  • One list of commands. Each entry has a name, an optional key and a function. The keydown listener, the palette and the help dialog all read it, so a new command appears in all three.
  • A dialog element. showModal() puts the palette above the page, and Escape closes it without extra code. HTML modal form covers the dialog element in detail.
  • Arrow keys inside the input. A second keydown listener on the palette input moves the highlight. preventDefault() there stops the caret from jumping to the start or end of the text.
  • Global shortcuts pause. While any dialog is open, single-key shortcuts return early.

A shortcut help dialog

Shortcuts nobody knows about go unused.

A common pattern is a list of shortcuts that opens with ? and is also linked from a visible button. GitHub, for example, opens its shortcut list with ?.

In the example, the table inside the help dialog is built from the same command list, so it cannot fall out of date.

The ? check compares e.key only. Shift is part of typing ? on a US keyboard, so the listener allows Shift and rejects only Ctrl, Cmd and Alt.

Make shortcuts accessible

Shortcuts should add speed, not replace buttons. Every action in the examples also has a button, which helps phone users, mouse users and anyone who never learns the keys.

  • Single-key shortcuts can clash with speech input. WCAG 2.1 success criterion 2.1.4 (Character Key Shortcuts) asks for a way to turn them off or remap them, or for them to work only while the related control has focus.
  • aria-keyshortcuts on a button tells assistive technology which shortcut it has, for example aria-keyshortcuts="Control+K". It announces the shortcut only; you still write the listener.
  • Focus must be somewhere on the page for key events to arrive. For making custom elements focusable, see tabindex.

When it does not work

What you see Cause Fix
Nothing happens in an embedded example The frame does not have keyboard focus Click inside it first
The shortcut fires while typing No text-field guard Return early when e.target is inside an input
Works on Windows, not on a Mac Only e.ctrlKey is checked Use e.metaKey on a Mac
Stops working with Caps Lock on e.key is "K", not "k" Lowercase e.key first
The browser's own action also runs No preventDefault() Call it in keydown when the combo matches
Ctrl+W or Ctrl+T closes or opens a tab anyway The browser reserves that combination Choose a different combination
The action runs over and over while held Key repeat Return early when e.repeat is true
Enter runs a command while typing Japanese or Chinese Input method composition Return early when e.isComposing is true

Shortcuts are hard to show in a screenshot: the point is pressing the keys. An .html attachment may open as plain code, or not at all, on a phone.

To send the working page, paste it into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can press Ctrl+K and try every shortcut themselves. If you change the code later, the same link shows the new version.

Questions people ask

Should I use keydown, keyup or keypress for shortcuts?

Use keydown. It fires before the browser acts on the key, so preventDefault can still stop the default action, and it fires for keys that type nothing, such as Escape and the arrows. keypress is deprecated, and keyup comes too late to block anything.

Should I compare e.key or e.code?

Compare e.key for shortcuts named after a character, such as Ctrl+K or ?. It follows the user's keyboard layout. Compare e.code when the physical position matters, such as W, A, S and D for movement in a game.

How do I make Ctrl+K work as Cmd+K on a Mac?

Detect a Mac once, then read e.metaKey on a Mac and e.ctrlKey elsewhere. Store the result in one variable and use it in every shortcut, so both platforms get the key they expect.

Why does my shortcut fire when I type in a text box?

Key events from an input bubble up to the document listener. Return early when e.target is inside an input, textarea, select or contenteditable element, unless the shortcut uses Ctrl or Cmd.

Can I override Ctrl+W or Ctrl+T?

No. Some combinations are handled by the browser or the operating system before the page sees them. In Chrome, Ctrl+N, Ctrl+T and Ctrl+W are examples. Pick combinations the page can actually receive.

Keep reading