Keyboard Shortcuts for the Rest of the World
If you’re a native English speaker, you probably don’t think too much about the special role that the English language plays in computer infrastructure. Programming languages use English keywords, domain names use Latin letters, terminal command names are based on English words...
If you don’t have a way to type in Latin characters, you’ll have a hard time using a computer at all. At the same time, there are many languages out there with alphabets that aren’t based on Latin. How the “rest of the world” deals with this is often a blind spot for developers used to Latin scripts. And sometimes this leads to a worse user experience.
Mostly, I want this article to cover a specific problem that comes up a lot when handling keyboard shortcuts in web applications: handling non-QWERTY layouts. So I’m going to put a snippet with a proper solution right here:
const actions = { "alt + shift + e": (event) => {}, // any other keyboard shortcuts... }; window.addEventListener("keydown", (event) => { const modifiers = ["meta", "ctrl", "alt", "shift"].filter( (key) => event[`${key}Key`] ); let key = event.key.toLowerCase(); if ( /^\p{Ll}$/u.test(key) && !/^[a-z]$/.test(key) && /^Key[A-Z]$/.test(event.code) ) { key = event.code.at(3).toLowerCase(); } const hotkey = [...modifiers, key].join(" + "); const action = actions[hotkey]; if (action) { event.preventDefault(); action(event); } });
Now that that’s out of the way, if you want to dive a little bit deeper, let’s start with the basics.
Disclaimer. Although I use two (and sometimes three) languages in my daily life, they all use simple phonetic LTR scripts, don’t use diacritics, don’t require input method editors — so I probably have my own blind spots!
Localized Keyboards
Your keyboard probably has keys with letters and symbols printed on them (oh yes, we’re starting with the very basics). When you press a key, the keyboard sends a numeric code that corresponds to the key you pressed — it’s called a scan code. The scan codes are assigned to each key by the keyboard hardware itself.
You’ll find that the position of the control keys like Ctrl
, Alt
, and Enter
doesn’t vary much from country to country, but other keys may have different letters printed on them. In the US, the first six letters on the keyboard spell QWERTY, but in France they sometimes spell AZERTY, or in Germany QWERTZ.
The Russian alphabet is completely different, with the first six letters spelling ЙЦУКЕН:
However, the physical keyboard doesn’t care about the symbols printed on its keycaps. Pressing Q
on a US QWERTY keyboard, A
on a French AZERTY keyboard, or Й
on a Russian ЙЦУКЕН keyboard sends out the same scan code. It’s up to the operating system to interpret it correctly.
Keyboard Layouts
That’s why operating systems support different keyboard layouts — mappings between the scan codes and the actual keys.
So if you have a QWERTZ keyboard, you can tell your operating system that you want to use the QWERTZ layout, and then there will be no mismatch between the keys you press on the keyboard and the characters you see on the screen.
Things get a little trickier if your alphabet is not Latin-based. If you want to be able to type both your local non-Latin characters and regular Latin, you’ll have to use two keyboard layouts.
For the most of my life, my keyboard looked like this:
Notice how it has two sets of characters printed on each key. One set is the regular QWERTY layout, and the second one is the Russian ЙЦУКЕН layout. I’ve never seen a dual-layout keyboard where one of the layouts was not QWERTY.
When you add two keyboard layouts in your operating system settings, an additional icon appears that shows which layout is currently active. You can click it (or better yet, use a special keyboard shortcut, like Win + Space
) to switch layouts.
The important things to remember are:
- Each key has at least two codes: a software-level code that depends on the current layout, and a hardware-level code that does not.
- Even for Latin-based scripts, the default keyboard layout is not always QWERTY.
- For the rest of the world, users usually have a dual layout setup, with QWERTY as the second layout.
Keyboard Shortcuts
Keyboard shortcuts are yet another thing deeply rooted in the English language. All the standard shortcuts, such as copy (Ctrl + C
), paste (Ctrl + V
), and undo (Ctrl + Z
), were designed with the US QWERTY keyboard in mind.
Because shortcuts are embedded in our physical memory, we should be very careful about changing them. For this reason, keyboard shortcuts should not be a subject to internationalization. Even if I change the UI language of my operating system to Russian, all shortcuts will still use Latin characters:
And since almost every keyboard has Latin characters (whether it’s just QWERTY, or QWERTY alongside some non-Latin layout), even if I type in Russian using the ЙЦУКЕН layout, I should still be able to look down at my keyboard, find the key with the letter Z
printed on it, and press Ctrl + Z
(which is actually Ctrl + Я
in ЙЦУКЕН) to undo. I shouldn’t have to remember my current layout in order to invoke a shortcut.
But that doesn’t mean we should rely solely on layout-independent codes (like scan codes) for keyboard shortcuts!
If I’m using a non-QWERTY Latin layout, it would be extremely confusing to me if the undo shortcut wasn’t Ctrl + Z
, even though Z
might be in an “unusual” place on my keyboard. Pressing Ctrl + Z
with the Z
in the top row of the AZERTY keyboard should also work as undo.
So there are really two cases:
- Either we are using a Latin keyboard layout (not necessarily QWERTY): then we should rely on layout-dependent code to figure out exactly which key was pressed;
- Or we are using a non-Latin layout: then it makes sense to assume that QWERTY is the secondary layout, and we should rely on layout-independent code to figure out which key it would have been in QWERTY.
Browser Keyboard Events
This sounds complicated, but the actual implementation is quite simple.
For keyboard events, the browser provides two event properties:
event.code is layout-independent. It is not a scan code per se, but rather a normalized name based on the scan code of the key: "ControlLeft"
, "ShiftRight"
, "Numpad0"
, and so on.
The convenient, if slightly confusing, thing is that for letter keys, event.code
has a name derived from the QWERTY layout: "KeyQ"
, "KeyW"
, and so on. And since this code is layout-independent, pressing the Й
key on the ЙЦУКЕН keyboard still results in event.code
equal to "KeyQ"
.
event.key is layout-dependent. If the key pressed is a printable character, event.key
is the character that would normally have been printed by pressing that key. Otherwise, it contains one of the predefined values, such as "Control"
or "Shift"
.
Implementation
And now we are ready to revisit the implementation from the beginning of this article:
const actions = { "alt + shift + e": (event) => {}, // any other keyboard shortcuts... }; window.addEventListener("keydown", (event) => { const modifiers = ["meta", "ctrl", "alt", "shift"].filter( (key) => event[`${key}Key`] ); let key = event.key.toLowerCase(); if ( /^\p{Ll}$/u.test(key) && !/^[a-z]$/.test(key) && /^Key[A-Z]$/.test(event.code) ) { key = event.code.at(3).toLowerCase(); } const hotkey = [...modifiers, key].join(" + "); const action = actions[hotkey]; if (action) { event.preventDefault(); action(event); } });
First, the event handler looks at the layout-dependent event.key
. If it is a Latin letter, or any other non-letter symbol, we use it as is. This covers the case of a user with a Latin layout, and since we rely on layout-dependent codes, non-QWERTY layouts are covered as well.
let key = event.key.toLowerCase();
However, if it is a non-Latin character (\p{Ll}
but not [a-z]
), the event handler looks at the layout-independent event.code
. There’s a good chance that in this case event.code
is Key*
, where *
is a Latin letter that occupies the same key in the QWERTY layout (e.g. KeyQ
). After all, non-Latin letters usually occupy the same keys as Latin letters do in QWERTY.
In this case, all we have to do is to trim the Key
prefix to get the Latin letter. This is a very simple (but slightly hacky) way to map non-Latin letters to the letters of the QWERTY layout.
if ( /^\p{Ll}$/u.test(key) && !/^[a-z]$/.test(key) && /^Key[A-Z]$/.test(event.code) ) { key = event.code.at(3).toLowerCase(); }
(I first came across this method in the KeyUX library. Its clever and compact implementation inspired this whole article.)
Of course, this implementation is not perfect. For example, you wouldn’t be able to invoke the Ctrl + ]
shortcut in the ЙЦУКЕН layout. The event.key
value is a non-Latin letter ъ
, but the event.code
is BracketRight
and not Key*
.
This can be solved with a more robust mapping between codes and symbols of the QWERTY layout, but I would just avoid using punctuation symbols in shortcuts altogether.
There’s a good reason for this. Lesser-used punctuation symbols, like square and curly brackets, ampersands, backslashes, and so on, are omitted from many keyboard layouts to make room for larger alphabets. This means keyboard shortcuts like Ctrl + ]
can confuse your users because they don’t know offhand where to find symbols like ]
on their keyboards.
More common punctuation, like commas, can be found in most keyboard layouts, but they are not always in the same place as in QWERTY (for example, in ЙЦУКЕН, you should press Shift + .
to enter a comma). So shortcuts like Ctrl + ,
are ambiguous, because it’s not immediately clear whether a user should be looking for a comma in QWERTY or in their local layout.
Unless you’re building professional software with lots of keyboard actions, it’s better to just stick to letters for your shortcuts.