A few weeks ago, I was working on converting zxcvbn to TypeScript when I started thinking — what other JavaScript libraries haven't been properly ported to TypeScript yet?
That rabbit hole led me to keyboard shortcut libraries. And what I found was shocking.
Every Popular Library Uses Deprecated APIs
I started auditing the most popular keyboard shortcut libraries on npm. Here's what I found:
| Library | Weekly Downloads | API Used |
|---|---|---|
| mousetrap | ~500k |
which (deprecated) |
| hotkeys-js | ~9M |
keyCode (deprecated) |
| react-hotkeys-hook | ~1.5M |
code (incorrect for i18n) |
| combokeys | ~200k |
which (deprecated) |
Every single one. keyCode, which, code — all deprecated or incorrect for international layouts.
Why Does This Matter?
The browser gives us two ways to identify which key was pressed:
-
KeyboardEvent.code— the physical position of the key on the keyboard -
KeyboardEvent.key— the actual character produced by the key
If you use code or keyCode, a user on a French keyboard pressing what they think is / will trigger your shortcut for , instead — because the physical key positions are different.
KeyboardEvent.key is the correct API. It gives you the actual character the user intended to type, regardless of their keyboard layout.
Every popular library gets this wrong. That means every shortcut built with these libraries is silently broken for a significant portion of users.
Why Not Just Port an Existing Library?
My first instinct was to port Mousetrap to TypeScript — it's the most well-known library and has a clean API. But then I checked the GitHub repo.
47 open pull requests. Zero merges in years. The maintainer has moved on.
There's no point submitting a PR that will never get reviewed. And there's no point porting a library that's built on the wrong foundation.
So I built a new one from scratch.
Introducing keystrok
keystrok is a modern, TypeScript-native keyboard shortcut library built on KeyboardEvent.key exclusively.
npm install keystrok
# React bindings
npm install keystrok-react
The API
import { keystrok } from 'keystrok'
// basic binding
keystrok.bind('ctrl+k', () => openSearch())
// prevent default
keystrok.bind('ctrl+s', save, { preventDefault: true })
// unbind
keystrok.unbind('ctrl+k')
Modifier aliases that feel natural
keystrok.bind('cmd+k', handler) // macOS users can use cmd
keystrok.bind('option+f', handler) // option instead of alt
keystrok.bind('win+k', handler) // win instead of meta
Sequence shortcuts
Multi-step shortcuts like GitHub's g then h to go home:
keystrok.bind(['g', 'h'], () => goHome())
keystrok.bind(['g', 's'], () => goSettings())
Scoped shortcuts
Activate different shortcuts based on context — perfect for modals, editors, or any contextual UI:
keystrok.bind('ctrl+b', bold, { scope: 'editor' })
keystrok.bind('escape', close, { scope: 'modal' })
// activate a scope
keystrok.scope('editor').activate()
// override mode — suppresses all other scopes
keystrok.scope('modal').override()
React bindings
import { useKeystrok, KeystrokScope } from 'keystrok-react'
function App() {
useKeystrok('ctrl+k', () => openSearch())
useKeystrok('ctrl+s', () => save(), { preventDefault: true })
return (
// auto-activates scope on mount, deactivates on unmount
<KeystrokScope name="editor">
<Editor />
</KeystrokScope>
)
}
Dev-time conflict detection
keystrok.bind('ctrl+k', openSearch)
keystrok.bind('ctrl+k', openCommandPalette)
// ⚠ [keystrok] Conflict detected: "ctrl+k" is already bound in scope "global". Both handlers will fire.
How I Built It
The library is a TypeScript monorepo with two packages:
-
keystrok— the framework-agnostic core -
keystrok-react— React hooks and components
The core is split into focused modules:
src/
├── types.ts — all TypeScript types
├── parser.ts — "ctrl+k" → ParsedKey
├── normalizer.ts — KeyboardEvent → ParsedKey
├── registry.ts — stores and looks up bindings
├── scope.ts — manages active scopes
├── sequence.ts — multi-step shortcut matching
└── keystrok.ts — wires everything together
Everything is tested with Bun's built-in test runner. The parser alone has 40+ tests covering modifier aliases, key aliases, whitespace handling, and error cases.
The key insight in the matching logic — when comparing a parsed shortcut against a live event, we check all four modifiers strictly. This means ctrl+k won't fire if the user also happens to be holding shift. Most libraries get this wrong too.
const modifiersMatch =
(parsed.modifiers.has('ctrl') === event.ctrlKey) &&
(parsed.modifiers.has('shift') === event.shiftKey) &&
(parsed.modifiers.has('alt') === event.altKey) &&
(parsed.modifiers.has('meta') === event.metaKey)
There's one interesting edge case — characters like ? and ! are naturally "shifted" characters. When you press ?, shiftKey is true in the event, but you don't want to have to write shift+? in your binding. keystrok detects these automatically:
const isShiftedChar =
parsed.key.length === 1 &&
parsed.modifiers.size === 0 &&
!/^[a-zA-Z0-9]$/.test(parsed.key)
What's Next
-
keystrok/vue— Vue composables -
keystrok/svelte— Svelte actions - Rebindable shortcuts — let users customize their own keybindings
- A visual shortcut reference component
Try It
npm install keystrok
npm install keystrok-react
If you've ever wondered why your keyboard shortcuts work for you but not for your French or German users — this is why. And now there's a fix.
Built with TypeScript, tested with Bun, zero dependencies.
Top comments (0)