Implementing Keyboard Shortcuts in Desktop Applications
Keyboard shortcuts work on two levels: local (only when app has focus) and global (works over other windows). Electron and Tauri implement both differently. A good system includes registry, parser, matcher, scope, and persistence.
Shortcut Architecture
// src/shortcuts/types.ts
export interface KeyCombo {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
export interface ShortcutDefinition {
id: string
combo: KeyCombo | KeyCombo[]
scope: string
label: string
action: () => void
allowInInput?: boolean
}
Parser and Normalization
// src/shortcuts/parser.ts
const KEY_ALIASES: Record<string, string> = {
'esc': 'Escape',
'del': 'Delete',
'return': 'Enter',
'space': ' ',
}
export function parseCombo(input: string): KeyCombo {
const parts = input.toLowerCase().split('+').map(p => p.trim())
const combo: KeyCombo = { key: '' }
for (const part of parts) {
switch (part) {
case 'ctrl': combo.ctrl = true; break
case 'shift': combo.shift = true; break
case 'alt': combo.alt = true; break
case 'meta': combo.meta = true; break
default: combo.key = part.toUpperCase()
}
}
return combo
}
export function matchesEvent(combo: KeyCombo, event: KeyboardEvent): boolean {
return (
event.key === combo.key &&
!!event.ctrlKey === !!combo.ctrl &&
!!event.shiftKey === !!combo.shift &&
!!event.altKey === !!combo.alt &&
!!event.metaKey === !!combo.meta
)
}
Registry
// src/shortcuts/registry.ts
export class ShortcutRegistry {
private shortcuts = new Map<string, ShortcutDefinition>()
register(def: ShortcutDefinition): ShortcutConflict | null {
const conflict = this.findConflict(def)
if (conflict) return conflict
this.shortcuts.set(def.id, def)
return null
}
unregister(id: string) {
this.shortcuts.delete(id)
}
updateCombo(id: string, newCombo: KeyCombo | string) {
const def = this.shortcuts.get(id)
if (!def) return
const parsed = typeof newCombo === 'string' ? parseCombo(newCombo) : newCombo
this.shortcuts.set(id, { ...def, combo: parsed })
}
handleKeyEvent(event: KeyboardEvent, currentScope: string): boolean {
const target = event.target as HTMLElement
const isInputFocused =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
for (const [, def] of this.shortcuts) {
if (def.scope !== currentScope && def.scope !== 'global') continue
if (isInputFocused && !def.allowInInput) continue
const combos = Array.isArray(def.combo) ? def.combo : [def.combo]
if (combos.some(c => matchesEvent(c, event))) {
event.preventDefault()
def.action()
return true
}
}
return false
}
}
Global Shortcuts in Electron
// main/global-shortcuts.ts
import { globalShortcut } from 'electron'
export function registerGlobalShortcuts(win: BrowserWindow) {
globalShortcut.register('CommandOrControl+Shift+Space', () => {
if (win.isVisible()) {
win.hide()
} else {
win.show()
win.focus()
}
})
globalShortcut.register('CommandOrControl+Shift+N', () => {
win.webContents.send('shortcut:quick-note')
})
return () => globalShortcut.unregisterAll()
}
Hook for Renderer
// src/hooks/useShortcuts.ts
import { useEffect, useRef } from 'react'
import { ShortcutRegistry } from '../shortcuts/registry'
import { ShortcutDefinition } from '../shortcuts/types'
const registry = new ShortcutRegistry()
export function useShortcuts(
shortcuts: Omit<ShortcutDefinition, 'id'>[],
scope: string
) {
useEffect(() => {
const ids: string[] = []
shortcuts.forEach((s, i) => {
const id = `${scope}-${i}-${Date.now()}`
const conflict = registry.register({ ...s, id, scope })
if (conflict) {
console.warn(`Shortcut conflict: "${s.label}" conflicts with "${conflict.existing.label}"`)
return
}
ids.push(id)
})
const handler = (e: KeyboardEvent) => {
registry.handleKeyEvent(e, scope)
}
document.addEventListener('keydown', handler)
return () => {
document.removeEventListener('keydown', handler)
ids.forEach(id => registry.unregister(id))
}
}, [])
}
User Customization
// main/shortcut-store.ts
import Store from 'electron-store'
const store = new Store()
export function getCustomBinding(id: string) {
return store.get(`customBindings.${id}`, null)
}
export function saveCustomBinding(id: string, combo: KeyCombo) {
store.set(`customBindings.${id}`, combo)
}
Basic implementation: 3–4 hours. Full system with registry, scopes, chords, global shortcuts, rebinding UI, persistence: 3–4 days.







