mokeys
v0.6.0
Published
> [!WARNING] > Pre 1.0. The API *will change*, without deprecation warnings. Things are still > in flux while I am using this in sideprojects.
Downloads
177
Readme
mokeys
[!WARNING] Pre 1.0. The API will change, without deprecation warnings. Things are still in flux while I am using this in sideprojects.
A small library for managing modal keyboard shortcuts for web frontends.
Installation
npm install mokeys
# or
pnpm add mokeys
# or
yarn add mokeysFeatures
- Zero dependencies
- Modal shortcuts - different keymaps based on mode (e.g., normal, visual, insert)
- Key sequences - multi-key bindings like
"g g"or"ctrl+w q" - Keymap management - add, remove, list, and override bindings dynamically
- Small - ~8.8kb uncompressed, <3kb gzipped
- Cross-platform modifier keys
super: cmd on macOS, ctrl on Windows/Linuxhyper: all four modifiers (ctrl+shift+alt+cmd)meh: ctrl+shift+alt
- Finite Statemachine - Mokeys is a FSM tailored towards keyboard input and dispatching actions and calling side-effects.
- swap out side-effects - re-use the same keybinds with different side-effects
- Fully typed - autocompletion for mode names
Quick Start
import { Keymap } from 'mokeys'
let index = 0
const items = ['a', 'b', 'c']
// Bind to a specific element
const editor = document.querySelector('#editor')
const keymap = new Keymap(editor, {
initial: 'normal',
modes: {
normal: {
'j': () => {
if (index < items.length - 1) index++
console.log('Current:', items[index])
},
'k': () => {
if (index > 0) index--
console.log('Current:', items[index])
},
}
}
})
// Or bind to window (global)
const globalKeymap = new Keymap({
initial: 'normal',
modes: {
normal: {
'ctrl+s': () => save()
}
}
})Usage
Constructor Signatures
Mokeys supports two constructor signatures:
// Bind to a specific element
const editor = document.querySelector('#editor')
const keymap = new Keymap(editor, {
initial: 'normal',
modes: { /* ... */ }
})
// Bind to window (default)
const keymap = new Keymap({
initial: 'normal',
modes: { /* ... */ }
})
// Or specify target in options
const keymap = new Keymap({
target: document.querySelector('#editor'),
initial: 'normal',
modes: { /* ... */ }
})Note: When using the (element, options) signature, the element parameter takes precedence over the target option.
Effects
Effects are a named collection of functions passed to the Keymap constructor. They get injected into every action's context, accessible via ctx.effects:
const keymap = new Keymap({
initial: 'normal',
effects: {
nextItem: () => { if (currentIndex < items.length - 1) currentIndex++ },
prevItem: () => { if (currentIndex > 0) currentIndex-- },
render: () => updateUI()
},
modes: {
normal: {
'j': ({ effects }) => {
effects.nextItem()
effects.render()
}
}
}
})Why use effects instead of direct function calls?
Decoupling - The keymap becomes a pure description of what should happen, not how. You can define your keymap once and swap the effects implementation:
const keybinds = { 'a': ({ effects }) => effects.save() } // Production new Keymap({ effects: { save: realSave }, modes: { normal: keybinds } }) // Tests new Keymap({ effects: { save: mockSave }, modes: { normal: keybinds } })Testability - Mock the entire effects object without module import hacks or closures.
Reusability - The same keymap config can be reused across different contexts with different effect implementations.
Type safety - Effects give you a typed contract; the keymap knows what effects are available at compile time.
When to skip effects: For simple apps or tightly coupled logic, direct function calls via closure are simpler:
'j': () => { moveDown(); }Effects shine when you need dependency injection or want to test/reuse keymaps in isolation from their side effects.
Swapping effects at runtime:
Use setEffects() to change effects without recreating the keymap - useful in SPAs when navigating between pages:
const keymap = new Keymap({
effects: { save: saveDocument, refresh: loadDocument },
modes: { normal: { 's': ({ effects }) => effects.save() } }
})
// On route change
keymap.setEffects({ save: saveUser, refresh: refreshUser })
// Only updates effects given:
keymap.setEffects({ save: saveWhatever }) // refresh is still loadDocument
// Reset all effects (removes effects not in the map)
keymap.setEffects({ save: saveWhatever }, true) // refresh is now undefinedInline Functions
You can also use inline functions without defining effects:
const keymap = new Keymap({
initial: 'normal',
modes: {
normal: {
'j': () => {
if (currentIndex < items.length - 1) currentIndex++
updateUI()
},
'k': () => {
if (currentIndex > 0) currentIndex--
updateUI()
}
}
}
})Binding Metadata
Add descriptions and groups to bindings for generating help UIs and documentation:
const keymap = new Keymap({
initial: 'normal',
effects: {
moveDown: () => { /* ... */ },
moveUp: () => { /* ... */ },
deleteItem: () => { /* ... */ }
},
modes: {
normal: {
// Plain function (no metadata)
'x': () => deleteChar(),
// With description and group
'j': {
description: 'Move to next item',
group: 'Navigation',
action: () => moveDown()
},
// String action - references an effect by name
'k': {
description: 'Move to previous item',
group: 'Navigation',
action: 'moveUp' // Calls effects.moveUp()
},
'd d': {
description: 'Delete current item',
group: 'Editing',
action: 'deleteItem'
}
}
}
})
// List bindings with metadata (flat)
const bindings = keymap.listCurrent()
// {
// 'j': { action: Function, description: 'Move to next item', group: 'Navigation' },
// 'x': { action: Function, description: undefined, group: undefined }
// }
// List bindings grouped by category
const grouped = keymap.listCurrent(true)
// {
// 'Navigation': [
// { key: 'j', description: 'Move to next item', action: Function },
// { key: 'k', description: 'Move to previous item', action: Function }
// ],
// 'Editing': [
// { key: 'd d', description: 'Delete current item', action: Function }
// ],
// '_ungrouped': [
// { key: 'x', description: undefined, action: Function }
// ]
// }
// Customize ungrouped key name
const grouped2 = keymap.listCurrent(true, { ungroupedKey: 'Other' })String actions: When action is a string, it references an effect by name. This keeps your keymap configuration declarative and makes it easy to swap implementations via setEffects().
Building a Help UI:
function showHelp(keymap: Keymap) {
const groups = keymap.listCurrent(true)
for (const [groupName, bindings] of Object.entries(groups)) {
if (groupName === '_ungrouped') continue
console.log(`\n## ${groupName}`)
bindings.forEach(({key, description}) => {
console.log(` ${key.padEnd(15)} ${description || ''}`)
})
}
}Modal Keymaps
Create different keymaps for different modes and transition between them:
const keymap = new Keymap({
initial: 'normal',
modes: {
normal: {
'v': ({keymap}) => keymap.setMode('visual'),
'i': ({keymap}) => keymap.setMode('insert'),
'j': () => moveDown(),
'k': () => moveUp()
},
visual: {
'j': () => {
moveDown()
extendSelection()
},
'escape': ({keymap}) => keymap.setMode('normal')
},
insert: {
'escape': ({keymap}) => keymap.setMode('normal')
}
}
})
// Programmatic mode transitions
keymap.setMode('visual')Keymap Management
Dynamically add, remove, override, and list keybindings:
// Add bindings
keymap.add('normal', 'x', () => deleteChar())
// Add bindings with metadata
keymap.add('normal', 'y', {
description: 'Yank (copy)',
group: 'Editing',
action: () => yank()
})
// Override existing bindings (last added wins)
keymap.add('normal', 'j', () => customNext())
// Remove bindings
keymap.remove('normal', 'j')
// List current mode's bindings (flat)
const bindings = keymap.listCurrent()
// List current mode's bindings (grouped)
const grouped = keymap.listCurrent(true)
// List specific mode's bindings (even if not current mode)
const normalBindings = keymap.list('normal')
const normalGrouped = keymap.list('normal', true)
// Add global bindings (work in all modes)
keymap.add({ 'ctrl+s': () => save() })Mode Transition Events
Hook into mode transitions with onEnter, onLeave, and onChange handlers. All handlers receive a transition object with from, to, and stop properties, plus the keymap instance.
Colocated handlers (defined in mode config):
const keymap = new Keymap({
initial: 'normal',
modes: {
normal: {
'i': ({keymap}) => keymap.setMode('insert'),
onLeave: (t, keymap) => {
if (hasUnsavedChanges) {
return t.stop // Cancel the transition
}
console.log(`Leaving normal for ${t.to}`)
}
},
insert: {
'escape': ({keymap}) => keymap.setMode('normal'),
onEnter: (t, keymap) => {
if (!canEdit) {
return t.stop // Cancel the transition
}
console.log(`Entered insert from ${t.from}`)
showCursor()
}
}
},
// Global onChange fires for every transition
onChange: (t, keymap) => {
console.log(`Mode changed: ${t.from} → ${t.to}`)
}
})Dynamic handlers (added after construction):
// Global handlers (fire for any mode)
keymap.onEnter((t, keymap) => console.log(`Entered ${t.to}`))
keymap.onLeave((t, keymap) => console.log(`Left ${t.from}`))
keymap.onChange((t, keymap) => console.log(`Changed to ${t.to}`))
// Mode-specific handlers
keymap.onEnter('insert', (t, keymap) => showCursor())
keymap.onLeave('insert', (t, keymap) => hideCursor())
// Remove handlers
keymap.removeEnter(handler)
keymap.removeEnter('insert', handler)
keymap.removeLeave(handler)
keymap.removeLeave('insert', handler)
keymap.removeChange(handler)Handler execution order: onLeave → onEnter → onChange
Any handler returning t.stop cancels the transition immediately, and subsequent handlers are not called.
Cross-Platform Modifiers
Use platform-aware modifier keys:
keymap.add('normal', {
'super+s': save, // cmd+s on macOS, ctrl+s elsewhere
'hyper+q': quit, // ctrl+shift+alt+cmd+q (all modifiers)
'meh+r': reload // ctrl+shift+alt+r (all except cmd)
})Key Sequences
Bind multi-key sequences:
keymap.add('normal', {
'g g': goToTop, // Press 'g' twice
'g t': goToTab, // Press 'g' then 't'
'ctrl+w q': closeWindow // Compound sequence
})Kitchen Sink
All features in a single example:
import { Keymap } from 'mokeys'
let index = 0
const items = ['a', 'b', 'c']
const selected = new Set()
const keymap = new Keymap({
initial: 'normal',
effects: {
nextItem: () => {
if (index < items.length - 1) index++
},
prevItem: () => {
if (index > 0) index--
},
firstItem: () => {
index = 0
},
deleteItem: () => {
items.splice(index, 1)
},
markItem: () => {
selected.add(index)
},
deleteSelection: () => {
[...selected].sort().reverse().forEach(i => items.splice(i, 1))
selected.clear()
},
render: () => updateUI(),
save: () => saveToServer()
},
global: {
'ctrl+s': ({effects}) => effects.save()
},
modes: {
normal: {
'j': ({effects}) => {
effects.nextItem()
effects.render()
},
'k': ({effects}) => {
effects.prevItem()
effects.render()
},
'd d': ({effects}) => {
effects.deleteItem()
effects.render()
},
'g g': ({effects}) => {
index === items.length - 1 ? effects.firstItem() : effects.nextItem()
effects.render()
},
'v': ({keymap}) => keymap.setMode('visual')
},
visual: {
'j': ({effects}) => {
effects.nextItem()
effects.markItem()
effects.render()
},
'k': ({effects}) => {
effects.markItem()
effects.prevItem()
effects.render()
},
'd': ({keymap, effects}) => {
effects.deleteSelection()
keymap.setMode('normal')
effects.render()
},
'escape': ({keymap}) => keymap.setMode('normal')
}
}
})
// Add custom bindings dynamically
keymap.add('normal', 'super+n', () => createNew())
// Listen to mode transitions
keymap.onEnter('visual', () => console.log('Visual mode active'))