# Internationalize Keyboard Shortcuts

**MUST** · **ID:** `interactions-locale-keyboard-shortcuts` · **Category:** interactions
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-locale-keyboard-shortcuts

> MUST: Bind mnemonic shortcuts to `event.key` (the character the layout produced), not `event.code` (a QWERTY key position — breaks on Dvorak/AZERTY); reserve `event.code` for positional bindings like WASD, and render `⌘/⌥/⇧` on macOS vs `Ctrl/Alt/Shift` elsewhere.

Bind shortcuts to the character the layout produces and render platform-specific modifier symbols

Two independent bugs hide behind a hardcoded "Ctrl+K" chip. The label: on macOS every other application says ⌘, so showing Ctrl tells the user your app is not theirs — read the platform and render ⌘ / ⌥ / ⇧ or Ctrl / Alt / Shift accordingly. The binding: event.code names a physical key position on a QWERTY board, so matching event.code === "KeyK" on a Dvorak layout fires from the key that prints "t", while the key printing "k" does nothing at all. For a mnemonic shortcut, match the character the layout actually produced (event.key) so the chip and the keyboard agree; reserve event.code for genuinely positional bindings like WASD, and use navigator.keyboard.getLayoutMap() to label the key with what it really prints.

## Rule snippet

```tsx
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") openPalette();
// NOT: e.code === "KeyK"
```

## Bad — do not do this

`interactions-locale-keyboard-shortcuts-bad`

```tsx
import { useState, type KeyboardEvent } from 'react';

// What each physical key (event.code) actually prints on a given layout.
const DVORAK: Record<string, string> = {
  KeyQ: "'", KeyW: ',', KeyE: '.', KeyR: 'p', KeyT: 'y', KeyY: 'f', KeyU: 'g', KeyI: 'c',
  KeyO: 'r', KeyP: 'l', KeyA: 'a', KeyS: 'o', KeyD: 'e', KeyF: 'u', KeyG: 'i', KeyH: 'd',
  KeyJ: 'h', KeyK: 't', KeyL: 'n', KeyZ: ';', KeyX: 'q', KeyC: 'j', KeyV: 'k', KeyB: 'x',
  KeyN: 'b', KeyM: 'm',
};
const AZERTY: Record<string, string> = { KeyA: 'q', KeyQ: 'a', KeyW: 'z', KeyZ: 'w', KeyM: ',' };

type Layout = 'qwerty' | 'azerty' | 'dvorak';

const prints = (layout: Layout, code: string, fallback: string) => {
  if (layout === 'dvorak') return DVORAK[code] ?? fallback;
  if (layout === 'azerty') return AZERTY[code] ?? fallback;
  return fallback;
};

export function LocaleKeyboardShortcutsBad() {
  const [layout, setLayout] = useState<Layout>('qwerty');
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState<{ code: string; printed: string; matched: boolean } | null>(null);

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'Tab' || (!e.ctrlKey && !e.metaKey)) return;

    // Bound to a physical key position, and only to Ctrl — no platform awareness.
    const matched = e.ctrlKey && e.code === 'KeyK';
    if (matched) {
      e.preventDefault();
      setOpen(true);
    }
    setLast({
      code: e.code,
      printed: prints(layout, e.code, e.key.toLowerCase()),
      matched,
    });
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <label className="flex items-center gap-2 text-xs text-muted-foreground">
          Simulated layout
          <select
            value={layout}
            onChange={(e) => setLayout(e.target.value as Layout)}
            className="rounded-md border border-border bg-muted px-2 py-1 text-foreground"
          >
            <option value="qwerty">QWERTY</option>
            <option value="azerty">AZERTY</option>
            <option value="dvorak">Dvorak</option>
          </select>
        </label>

        <div
          tabIndex={0}
          onKeyDown={onKeyDown}
          className="rounded-md border border-border bg-muted p-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
        >
          <div className="mb-2 text-xs text-muted-foreground">
            Focus here, then hold Ctrl (or ⌘) and press a letter.
          </div>
          <div className="flex items-center justify-between text-sm text-foreground">
            <span>Command palette</span>
            {/* Hardcoded, never platform-aware. */}
            <kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-xs">Ctrl+K</kbd>
          </div>
        </div>

        {last && (
          <div className="space-y-1 text-xs tabular-nums text-muted-foreground">
            <div>
              physical key <code>{last.code}</code> · prints{' '}
              <code>&ldquo;{last.printed}&rdquo;</code> on {layout}
            </div>
            <div className={last.matched ? 'text-error' : 'text-muted-foreground'}>
              {last.matched
                ? `palette opened — but you had to press the key that prints "${last.printed}"`
                : 'no match'}
            </div>
          </div>
        )}

        {open && (
          <div className="rounded-md border border-border bg-muted p-2 text-xs text-foreground">
            Command palette <button type="button" className="underline" onClick={() => setOpen(false)}>close</button>
          </div>
        )}
      </div>
      <p className="text-xs text-error">
        The chip is a hardcoded string and the binding is a physical key position (
        <code>event.code === &apos;KeyK&apos;</code>). Switch to Dvorak: the key that opens the
        palette is the one that prints <code>t</code>, while the key printing <code>k</code> does
        nothing. On a Mac the chip still says Ctrl, where every other app says ⌘.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-locale-keyboard-shortcuts-good`

```tsx
import { useState, type KeyboardEvent } from 'react';

const DVORAK: Record<string, string> = {
  KeyQ: "'", KeyW: ',', KeyE: '.', KeyR: 'p', KeyT: 'y', KeyY: 'f', KeyU: 'g', KeyI: 'c',
  KeyO: 'r', KeyP: 'l', KeyA: 'a', KeyS: 'o', KeyD: 'e', KeyF: 'u', KeyG: 'i', KeyH: 'd',
  KeyJ: 'h', KeyK: 't', KeyL: 'n', KeyZ: ';', KeyX: 'q', KeyC: 'j', KeyV: 'k', KeyB: 'x',
  KeyN: 'b', KeyM: 'm',
};
const AZERTY: Record<string, string> = { KeyA: 'q', KeyQ: 'a', KeyW: 'z', KeyZ: 'w', KeyM: ',' };

type Layout = 'qwerty' | 'azerty' | 'dvorak';

const prints = (layout: Layout, code: string, fallback: string) => {
  if (layout === 'dvorak') return DVORAK[code] ?? fallback;
  if (layout === 'azerty') return AZERTY[code] ?? fallback;
  return fallback;
};

// Where the letter "k" physically sits, the way navigator.keyboard.getLayoutMap() would tell you.
const homeOf = (layout: Layout) => {
  const map = layout === 'dvorak' ? DVORAK : layout === 'azerty' ? AZERTY : {};
  const found = Object.entries(map).find(([, char]) => char === 'k');
  return found ? found[0] : 'KeyK';
};

const detectApple = () =>
  typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);

export function LocaleKeyboardShortcutsGood() {
  const [layout, setLayout] = useState<Layout>('qwerty');
  const [isApple, setIsApple] = useState(detectApple);
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState<{ code: string; printed: string; matched: boolean } | null>(null);

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'Tab' || (!e.ctrlKey && !e.metaKey)) return;

    // Platform-correct modifier, and match the letter the layout actually produces.
    const modifier = isApple ? e.metaKey : e.ctrlKey;
    const printed = prints(layout, e.code, e.key.toLowerCase());
    const matched = modifier && printed === 'k';
    if (matched) {
      e.preventDefault();
      setOpen(true);
    }
    setLast({ code: e.code, printed, matched });
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
          <label className="flex items-center gap-1">
            Layout
            <select
              value={layout}
              onChange={(e) => setLayout(e.target.value as Layout)}
              className="rounded-md border border-border bg-muted px-2 py-1 text-foreground"
            >
              <option value="qwerty">QWERTY</option>
              <option value="azerty">AZERTY</option>
              <option value="dvorak">Dvorak</option>
            </select>
          </label>
          <label className="flex items-center gap-1">
            Platform
            <select
              value={isApple ? 'apple' : 'other'}
              onChange={(e) => setIsApple(e.target.value === 'apple')}
              className="rounded-md border border-border bg-muted px-2 py-1 text-foreground"
            >
              <option value="apple">macOS</option>
              <option value="other">Windows / Linux</option>
            </select>
          </label>
        </div>

        <div
          tabIndex={0}
          onKeyDown={onKeyDown}
          className="rounded-md border border-border bg-muted p-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
        >
          <div className="mb-2 text-xs text-muted-foreground">
            Focus here, then press the shortcut shown on the chip.
          </div>
          <div className="flex items-center justify-between text-sm text-foreground">
            <span>Command palette</span>
            <span className="flex gap-1">
              <kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-xs">
                {isApple ? '⌘' : 'Ctrl'}
              </kbd>
              <kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-xs">K</kbd>
            </span>
          </div>
        </div>

        <div className="space-y-1 text-xs text-muted-foreground">
          <div>
            on {layout}, <code>k</code> is printed by physical key <code>{homeOf(layout)}</code>
          </div>
          {last && (
            <>
              <div>
                physical key <code>{last.code}</code> · prints{' '}
                <code>&ldquo;{last.printed}&rdquo;</code>
              </div>
              <div className={last.matched ? 'text-success' : 'text-muted-foreground'}>
                {last.matched ? 'palette opened — the key that prints "k" worked' : 'no match'}
              </div>
            </>
          )}
        </div>

        {open && (
          <div className="rounded-md border border-border bg-muted p-2 text-xs text-foreground">
            Command palette{' '}
            <button type="button" className="underline" onClick={() => setOpen(false)}>
              close
            </button>
          </div>
        )}
      </div>
      <p className="text-xs text-success">
        The modifier symbol comes from the platform (⌘ on Apple, Ctrl elsewhere) and the binding
        matches the character the layout produces, not a QWERTY key position. Switch to Dvorak and
        press the key that prints <code>k</code>: it opens, exactly as the chip promised.
      </p>
    </div>
  );
}
```

## References

- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code)
- [MDN: Keyboard.getLayoutMap()](https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/getLayoutMap)
