The problem
If you've built a command palette, a shortcuts help dialog, or a scope-gated editor, you've hit the same walls:
- Layouts break shortcuts. On German QWERTZ, the physical Z key types "y". Libraries that match on
event.keyalone break undo. Libraries onkeyCodeare deprecated and still wrong in subtler ways. - Context is duct tape.
if (editorFocused && !modalOpen)scattered across handlers doesn't scale. You want declarative gating — the way VS Code does it. - Collisions are silent. Two components register
mod+k. One wins. Which one? Why? Good luck.
What Tactile is
Tactile is a small, framework-agnostic keyboard shortcut engine with a React adapter. It's modeled on VS Code's keybinding system — not a thin wrapper around addEventListener.
import { createKeybindingEngine } from '@tactile-js/core';
const kb = createKeybindingEngine();
kb.add({
id: 'palette.open',
keys: 'mod+k',
when: "scope == 'global'",
group: 'Navigation',
description: 'Open command palette',
handler: () => openPalette(),
});Two packages:
@tactile-js/core— the engine. No React required.@tactile-js/react—KeybindProvider,useShortcut,useScope,useKeymap,useShortcutRecorder.
Layout-aware matching (the important part)
Tactile matches on event.code and event.key — neverkeyCode. The default hybrid mode:
- Letters & digits → physical keycap position (
event.code) - Symbols & named keys → character / logical key (
event.key)
So mod+z undo fires on the Z keycap on QWERTZ (even though it types "y"), and? matches the glyph internationally. This is the same convention VS Code uses — we verified it against their codebase.
On Dvorak, ⌘K means the K keycap, not the key that types "k". That's intentional. We document it honestly and offer match: 'logical' when you need character-based matching.
When expressions, not flags
kb.add({
keys: 'mod+b',
when: "scope == 'editor' && !readOnly",
handler: toggleBold,
});Scopes are just context keys. No parallel concept to learn. In React,useScope() is sugar over engine.context.set().
Collisions are first-class
kb.getCollisions();
// [{ keys: 'mod+k', rules: [{ id: 'a' }, { id: 'b' }] }]At runtime, higher priority wins; ties go to the most recently registered rule. Same model as VS Code.
Introspection built in
getKeymap() returns every binding with platform-formatted labels (⌘K /Ctrl+K). Build a shortcuts dialog from it — the UI can't drift from what's actually registered.
Try it
npm install @tactile-js/reactWhat's next
Tactile is early — pre-1.0, API settling. Vue and Svelte adapters are on the roadmap. If you try it, file issues, or tell us what breaks on your layout, we'll listen.
Keyboard shortcuts on the web will never be perfect. OS-reserved keys, IME composition, and layout label drift are hard problems. Tactile handles the common cases correctly, documents the rest, and gives you an engine that scales past five global hotkeys.
— Lohith Mallikarjun