DEV Community

Cover image for Why Every JavaScript Keyboard Shortcut Library Is Broken (And What I Built Instead)
Kunal Tanwar
Kunal Tanwar

Posted on

Why Every JavaScript Keyboard Shortcut Library Is Broken (And What I Built Instead)

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Sequence shortcuts

Multi-step shortcuts like GitHub's g then h to go home:

keystrok.bind(['g', 'h'], () => goHome())
keystrok.bind(['g', 's'], () => goSettings())
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)