Engineering

Keyboard shortcuts that work on both Mac and Windows

One modifier, two platforms. Redo is the shortcut people disagree on, IME composition must not trigger shortcuts, and the browser's own keys should stay the browser's.

7 min read

Ctrl+B on Windows and Cmd+B on Mac both make text bold. Most editors get that mapping right on the first try. The problems are in the details: redo on Mac, shortcuts that fire while a Japanese or Chinese user is still choosing characters, letters that move when the keyboard layout changes, and browser shortcuts the editor should leave alone.

One modifier, two platforms

Treat the platform’s main modifier as one concept, which most people call Mod. On Windows and Linux it is Ctrl. On macOS it is Cmd, which the browser reports as metaKey. Work out the platform once, using the user-agent client hints where they exist and falling back to navigator.platform:

Code example
type UaNavigator = Navigator & { userAgentData?: { platform: string } };

export function isMacPlatform(): boolean {
  const nav = navigator as UaNavigator;
  const platform = nav.userAgentData?.platform ?? navigator.platform ?? "";
  return /mac/i.test(platform);
}

// True when the platform's main modifier is held, and the other one is not.
export function isMod(e: KeyboardEvent, mac: boolean): boolean {
  return mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
}

Do not test for ctrlKey || metaKey. On Mac that accepts Ctrl+B as well, which some users press by accident from habit, and it lets a Windows Cmd-key habit trigger an action the user did not mean.

Redo is the shortcut that differs

Undo is Mod+Z on both platforms. Redo is where people disagree, so support every form users are likely to press:

Code example
function onKeyDown(e: KeyboardEvent, mac: boolean) {
  // IME users (Japanese, Chinese, Korean) press Enter, arrows and letters to
  // choose candidates. Those keystrokes belong to the input method.
  if (e.isComposing || e.keyCode === 229) return;
  if (!isMod(e, mac)) return;

  const key = e.key.toLowerCase();

  if (key === "b" && !e.shiftKey && !e.altKey) {
    e.preventDefault();
    toggleBold();
  } else if (key === "z" && !e.altKey) {
    e.preventDefault();
    if (e.shiftKey) redo();
    else undo();
  } else if (key === "y" && !mac && !e.shiftKey) {
    e.preventDefault();
    redo();
  }
}

The isComposing check matters more than it looks. While an IME is open, a keydown with a letter key is part of the composition, and toggling bold in the middle of it corrupts the text being typed. The keyCode === 229 check covers older Safari, which reports composition keydowns that way.

Letters move with the keyboard layout

e.keyis the character the user’s layout produces, so it is what a French or German user expects to match. The catch is the Option key on Mac: with Option held, the character changes. On a US layout, Option+B produces “∫”, so a check on e.key never matches a letter shortcut with Option in it. Avoid Option in editor shortcuts for that reason. If you need an Option combination, match e.code, which names the physical key (KeyB) regardless of layout.

That means a letter shortcut matches the character the user sees on their keyboard, and a shortcut with Option matches the physical position. Both are reasonable. Pick one per shortcut and document it.

Shortcuts the editor should leave alone

Some shortcuts belong to the browser or the operating system, and users who press them expect those to act. Do not call preventDefault() on any of these:

The rule is simple: an editor handles keys that edit text, and leaves window and browser keys alone. When you do handle a key, call preventDefault() only in that branch, so every other key keeps its normal behaviour.

Show the shortcut the user actually presses

Toolbar tooltips and menus should show the key the user is expected to press. A Mac user reads “Cmd+B” and a Windows user reads “Ctrl+B”. Build the label from the same mac flag that the handler uses, so the two cannot disagree:

Code example
export function shortcutLabel(key: string, mac: boolean): string {
  return mac ? `Cmd+${key.toUpperCase()}` : `Ctrl+${key.toUpperCase()}`;
}

A short test checklist

Keyboard handling is easy to ship and easy to get subtly wrong. A dozen minutes of this checklist catches most of what users would otherwise report as a bug in the editor.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.