Headless Svelte 5 library for managing all keyboard input in a web app. One dispatcher, declarative commands, a trie of bindings, reactive state. UI is yours to build.
Status: v0.1 in development. Public API stabilising; not yet on npm. See DESIGN.md for the locked spec.
Single-key hotkeys, VS Code-style chord sequences (Ctrl+K Ctrl+S), and
vim-style leader menus (<leader> b r) are usually three different patterns
served by three different libs. In keyman they're all paths through one
trie — depth is the only thing that changes. Hotkeys are depth-1 paths;
leader sequences are deeper paths.
keyman ships zero UI. It owns the document keydown listener, the command registry, the trie, and the reactive state. UI components read that state and render whatever shape they want.
import { createKeyman } from 'keyman';
// 1. App context — anything reactive that `when` predicates might read.
const ctx = $state({ modalOpen: false /* ... */ });
// 2. Create an engine. Safe at module load (no DOM access yet).
export const keyman = createKeyman({
leader: '<Space>',
context: () => ctx
});
// 3. Register the keymap. Order-independent; validated on start().
keyman.registerCommands([
{ id: 'file.save', label: 'Save File', run: () => /* ... */ }
]);
keyman.registerBindings([
{ keys: '<leader> f s', commandId: 'file.save' },
{ keys: '$mod+s', commandId: 'file.save', allowBrowserShadow: true }
]);
// 4. Attach the document keydown listener after mount.
// In SvelteKit: usually in +layout.svelte's onMount.
keyman.start();
keyman.currentSequence // string[] — keys pressed so far in the current chain
keyman.isInMenu // boolean — true between leader-press and dismiss
keyman.pendingError // { key } | null — last unmapped key, "stuck" state
keyman.lastFired // { commandId, timestamp, args } | null — for toasts
keyman.lastError // { kind, message, timestamp } | null
Read these inside $effect / $derived in your UI components. Don't
destructure — capturing values breaks reactivity across the export
boundary. See docs/m5-engine-tutorial.md
for why.
id, label, run.commandId. Many bindings can fire one command.when predicates read.Synonyms (shortcut, hotkey, group, category, handler) are bugs.
DESIGN.md — full spec, source of truth.docs/m5-engine-tutorial.md — engine plumbing: factory, $state+getter pattern, lifecycle, IME guard, keypress→UI data flow.docs/m5-slice-2-tutorial.md — trie walk, command dispatch, park-with-error UX.npm run dev # playground at localhost (src/routes/)
npm run check # svelte-check
npm run test:unit # vitest
Repo layout per DESIGN.md §12.1 — flat files in src/lib/, co-located *.test.ts.