Skip to content
tactile

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
  • Letters → physical. ⌘K should 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 physical KeyZ produces “y”, ⌘Z still 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, F5 have stable, layout-independent key values.

Read International keyboards for the Dvorak/keycap convention and help UI implications.

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 });

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 '?', matches

For each keydown (or keyup if eventType: 'keyup'):

  1. Ignore check — skip if focused in an input (configurable).
  2. Context check — evaluate each rule’s when against engine.context.
  3. Chord match — compare modifiers + main key using the rule’s match mode.
  4. Sequence check — for g i, match the last N keystrokes within sequenceTimeout.
  5. Collision resolve — highest priority, then last registered wins.
  6. Handler — call with (event, { id, keys, sequence }).
// German QWERTZ: physical Z produces 'y'
event = { key: 'y', code: 'KeyZ', ctrlKey: true }
match('ctrl+z', event, 'hybrid') // ✅ true — Z keycap
match('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 matters
event = { key: '?', code: 'Slash', shiftKey: true }
match('?', event, 'hybrid') // ✅ true

For custom tooling, use matchChord directly:

import { parseBinding, matchChord } from '@tactile-js/core';
const chord = parseBinding('mod+z', 'other')[0];
matchChord(chord, keyboardEvent, 'hybrid');