event.key vs event.code: match modes explained
This is the single most important design decision in Tactile, and the thing the popular incumbents
get wrong by leaning entirely on the deprecated keyCode.
The engine keeps two ideas separate: the character a key produces (KeyboardEvent.key) and its
physical position (KeyboardEvent.code). Each binding token is matched by one or the other,
chosen by the match mode.
| Mode | Letters & digits | Named keys (Enter, ↑, F5) | Symbols (?, /) |
|---|---|---|---|
hybrid (default) |
physical (code) |
logical (key) |
logical (key) |
physical |
physical | physical | falls back to key |
logical |
logical | logical | logical |
Why hybrid defaults the way it does
Section titled “Why hybrid defaults the way it does”- Letters → physical.
⌘Kshould be the K position — that’s what app authors almost always mean, and it stays put when the layout changes the character. On a German layout where the physicalKeyZproduces “y”,⌘Zstill fires on the Z key.keyCode-based libraries get this wrong. - Symbols → logical. You mean the glyph
?, whose physical key differs by layout. Matching the character is the only thing that behaves internationally. - Named keys → logical.
Enter,ArrowUp,F5have stable, layout-independentkeyvalues.
Read International keyboards for the Dvorak/keycap convention and help UI implications.
Configure match mode
Section titled “Configure match mode”Globally:
const kb = createKeybindingEngine({ defaultMatch: 'hybrid' });Per rule:
kb.add({ id: 'vim.down', keys: 'j', match: 'physical', handler: down });kb.add({ id: 'search', keys: 'mod+k', match: 'logical', handler: search });Modifier matching
Section titled “Modifier matching”Ctrl, Alt, Meta are matched exactly. Shift is matched exactly except for symbol main keys:
producing ? already requires Shift on many layouts, so enforcing Shift in the binding would make
'?' impossible to register.
// These are equivalent on US QWERTY for the ? key:kb.add({ id: 'help', keys: '?', handler: help });// User presses Shift+/ → event.key is '?', matchesHow matching works at runtime
Section titled “How matching works at runtime”For each keydown (or keyup if eventType: 'keyup'):
- Ignore check — skip if focused in an input (configurable).
- Context check — evaluate each rule’s
whenagainstengine.context. - Chord match — compare modifiers + main key using the rule’s match mode.
- Sequence check — for
g i, match the last N keystrokes withinsequenceTimeout. - Collision resolve — highest
priority, then last registered wins. - Handler — call with
(event, { id, keys, sequence }).
Test cases (what we verify)
Section titled “Test cases (what we verify)”// German QWERTZ: physical Z produces 'y'event = { key: 'y', code: 'KeyZ', ctrlKey: true }match('ctrl+z', event, 'hybrid') // ✅ true — Z keycapmatch('ctrl+y', event, 'hybrid') // ❌ false — not the Y keycap
// Dvorak: physical K produces 't'event = { key: 't', code: 'KeyK', metaKey: true }match('mod+k', event, 'hybrid') // ✅ true — K keycap
// Symbol: character mattersevent = { key: '?', code: 'Slash', shiftKey: true }match('?', event, 'hybrid') // ✅ trueInspect your hardware
Section titled “Inspect your hardware”Lower-level API
Section titled “Lower-level API”For custom tooling, use matchChord directly:
import { parseBinding, matchChord } from '@tactile-js/core';
const chord = parseBinding('mod+z', 'other')[0];matchChord(chord, keyboardEvent, 'hybrid');