Keyboard, mouse & pointer bindings without the weight.
A zero-dependency TypeScript library for DOM keyboard, mouse and pointer event bindings, packed into a couple of kilobytes with zero-allocation dispatch.
| Core (no chords) | 1.6 KB gzip |
| With chords | 1.8 KB gzip |
| Dependencies | 0 |
| Dispatch throughput | ~2.5M/sec |
npm install @farskid/kilid- Compact numeric encoding - bindings are single numbers:
KeyMod.CtrlCmd | KeyCode.KeyS, chords viaKeyChord(...), one integer per binding, resolved at registration. - Zero-allocation dispatch - every event reduces to one integer hash and one
Maplookup. No strings, objects or closures on the hot path, flat cost at any binding count. - Pay only for what you use - chords, string parsing, the pointer service and the React adapter are separate modules. A
Cmd+S-only app ships 1.6 KB. - Cross-platform by default -
KeyMod.CtrlCmdmeans Cmd on macOS and Ctrl elsewhere. One binding, correct everywhere, resolved once at registration. - Layout-independent - bindings match physical keys via
KeyboardEvent.code, with anevent.keyfallback for exotic keyboards. - One pointing surface - pointer events subsume mouse. A single service covers down/move/click/wheel with the same modifier encoding, plus pen/touch filters.
| Library | Keyboard | Chords | Pointer / mouse | Zero deps | Tree-shakeable | Framework hooks | Typical core gzip |
|---|---|---|---|---|---|---|---|
| kilid | ✓ | ✓ opt-in | ✓ | ✓ | ✓ | React | 1.6 KB |
| Mousetrap | ✓ | ✓ | — | ✓ | — | — | ~2 KB |
| hotkeys-js | ✓ | — | — | ✓ | partial | — | ~3 KB |
| tinykeys | ✓ | ✓ | — | ✓ | — | — | ~0.7 KB |
| react-hotkeys-hook | ✓ | ✓ | — | — | partial | React | ~4 KB+ |
| @tanstack/react-hotkeys | ✓ | ✓ | — | — | partial | React | ~5 KB+ |
| cmdk (palette UI) | partial | — | — | — | — | React | ~8 KB+ |
Competitor sizes are approximate (typical single-import usage). kilid numbers are from CI size scenarios.
When kilid fits: editors, canvases, dashboards, keyboard + pointer + chords in one package. When something else fits: keyboard-only React hotkeys (tinykeys, react-hotkeys-hook); command palette UI (cmdk, pair with kilid, see Recipes).
import { KeyMod, KeyCode, keybindings } from '@farskid/kilid';
const keys = keybindings(window);
// Cmd+S on macOS, Ctrl+S elsewhere — a single-part binding
keys.add(KeyMod.CtrlCmd | KeyCode.KeyS, (e) => save());
// Guards and event control
const off = keys.add(KeyMod.CtrlCmd | KeyCode.KeyP, quickOpen, {
when: () => !modalIsOpen, // evaluated at dispatch
preventDefault: true, // default true for keyboard
});
off(); // add() returns an unsubscribe function
keys.dispose(); // removes all bindings and the DOM listenerEverything ships from two entry points: @farskid/kilid (core) and @farskid/kilid/react (adapter). All factories return plain objects; all add() calls return an unsubscribe function. Invalid encodings register nothing, the core never throws; in development builds a console.warn explains why (stripped from production bundles via process.env.NODE_ENV).
Single-part keybinding dispatcher (Cmd+S, F2, Ctrl+Shift+P) using one keydown listener. Options: isMac (override platform detection), capture.
const keys = keybindings(element, { isMac: false });
const off = keys.add(encoded, handler, { when, preventDefault, stopPropagation });
keys.dispose();Drop-in superset of keybindings that also handles two-part chords (Ctrl+K Ctrl+S) with prefix-then-second-key semantics: a chord prefix shadows single bindings on the same combo, an unmatched second key is swallowed, and the pending prefix expires after chordTimeout (default 5000 ms). Separate module, apps without chords never ship the state machine.
Note: Cmd+S is a single binding with a modifier, not a chord; chords are two sequential keypresses.
import { KeyChord, chordKeybindings } from '@farskid/kilid';
const keys = chordKeybindings(window);
keys.add(
KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyS),
openKeyboardShortcuts
);
keys.isChordPending; // true between Ctrl+K and the second partOne service for the whole pointing surface. Event kinds: down, up, move, enter, leave, cancel, click, dblclick, contextmenu, wheel. DOM listeners attach lazily per kind and detach when the last binding of that kind unsubscribes. preventDefault defaults to false here.
import { KeyMod, MouseButton, pointerBindings } from '@farskid/kilid';
const pointer = pointerBindings(element);
pointer.add(KeyMod.CtrlCmd | MouseButton.Left, 'click', addToSelection);
pointer.add(MouseButton.Middle, 'down', startPan);
pointer.add(KeyMod.CtrlCmd | MouseButton.WheelUp, 'wheel', zoomIn, { preventDefault: true });
// Buttonless kinds (move/enter/leave/cancel) take no button, with pen/touch filters
pointer.add('move', onDraw, { pointerType: ['pen', 'touch'] });
// Modifier-only encoding: move while Alt (or Cmd+Alt) is held
pointer.add(KeyMod.Alt, 'move', onAltDraw);
pointer.add(KeyMod.CtrlCmd | KeyMod.Alt, 'move', onOrbit);For move/enter/leave/cancel (where button is -1), use the buttonless overload add('move', handler) or a modifier-only encoding, button bits register nothing there (dev builds warn). Pointer events don't carry non-modifier key state, so "move while K is held" needs a when guard fed by your own keydown/keyup tracking. Use when with event.buttons to filter by held buttons.
A binding is one 32-bit number with a fixed bit layout:
15 14 13 12 11 10 9 8 7 ... 0
- - C S A W [ key code ] C = CtrlCmd S = Shift A = Alt W = WinCtrl
KeyChord(first, second) // packs the second part into bits 16–31
| Export | Meaning |
|---|---|
KeyMod.CtrlCmd |
Cmd on macOS, Ctrl on Windows/Linux |
KeyMod.WinCtrl |
Ctrl on macOS, Win/Meta on Windows/Linux |
KeyMod.Shift, KeyMod.Alt |
Shift; Alt (Option on macOS) |
KeyCode.* |
Layout-independent key codes, KeyA–Z, Digit0–9, F1–19, Numpad0–9, Enter, Escape, arrows, punctuation, … (names match KeyboardEvent.code) |
MouseButton.* |
Left, Middle, Right, X1, X2, WheelUp/Down/Left/Right |
keyCodeFromEvent(e) |
Resolve a live KeyboardEvent to a key code |
isModifierKeyCode(c) |
True for Shift/Ctrl/Alt/Meta |
decodeKeybinding(n, isMac) |
Encoded binding → platform-resolved parts |
String convenience lives in its own module with lazily built tables, so it only ships to bundles that import it. The core add() is numeric-only by design, the parser is never pulled in behind your back.
import { parseKeybinding, formatKeybinding } from '@farskid/kilid';
keys.add(parseKeybinding('Ctrl+Shift+P'), quickOpen);
keys.add(parseKeybinding('Ctrl+K Ctrl+S'), openShortcuts); // chordKeybindings only
formatKeybinding(KeyMod.CtrlCmd | KeyCode.KeyS); // "Ctrl+S"
formatKeybinding(KeyMod.CtrlCmd | KeyCode.KeyS, { isMac: true }); // "⌘S"In strings, Ctrl, Cmd, Meta and Mod all map to KeyMod.CtrlCmd so one string works on every platform; use WinCtrl/Super for the secondary modifier. Also exported: keyCodeToString, keyCodeFromString (accepts aliases like Esc).
Common patterns that aren't separate API options, capture is a factory flag, one-shot bindings are a few lines of glue, and delegation is native DOM bubbling on whatever EventTarget you pass.
Pass capture: true when creating the service. It applies to the whole dispatcher's DOM listener (all bindings on that instance), not per-binding. keybindings, chordKeybindings, and pointerBindings all accept it. React hooks pass it through as capture (service-level, hooks on the same target share a listener only when capture matches).
import { KeyMod, KeyCode, MouseButton, keybindings, pointerBindings } from '@farskid/kilid';
// Intercept before targets deeper in the tree (e.g. stop browser shortcuts early)
const keys = keybindings(document, { capture: true });
keys.add(KeyMod.CtrlCmd | KeyCode.KeyS, save);
const list = document.getElementById('list');
const pointer = pointerBindings(list, { capture: true });
pointer.add(MouseButton.Left, 'click', onRowClick);There is no { once: true } option, call the unsubscribe function returned by add() inside the handler when you only want one fire.
const keys = keybindings(window);
const off = keys.add(KeyMod.CtrlCmd | KeyCode.KeyS, (e) => {
off(); // unregister before running, safe even if save throws
save(e);
});
// same idea for pointer
const pointer = pointerBindings(element);
const offClick = pointer.add(MouseButton.Left, 'click', (e) => {
offClick();
confirmOnce(e);
});Attach to a parent; bubbling events from children reach the listener. There is no built-in selector API, filter with event.target and Element.closest() inside the handler. The when guard receives no event, so it can't filter by target; use it for app-state conditions (() => !modalIsOpen) instead. pointerenter/pointerleave do not bubble; bind those on the element you care about. keydown bubbles too, but its target is whatever element has focus, so keyboard bindings usually go on window rather than a container.
const list = document.getElementById('list');
const pointer = pointerBindings(list);
pointer.add(MouseButton.Left, 'click', (e) => {
const row = e.target.closest('[data-id]');
if (!row) return;
select(row.dataset.id);
}, {
when: () => !modalIsOpen, // app-state guard, checked before the handler runs
});Import @farskid/kilid/testing to dispatch DOM events that match the same numeric encodings your app registers:
import { KeyMod, KeyCode, MouseButton, keybindings } from '@farskid/kilid';
import { dispatchKeybinding, dispatchPointerBinding } from '@farskid/kilid/testing';
const keys = keybindings(window);
keys.add(KeyMod.CtrlCmd | KeyCode.KeyS, save);
dispatchKeybinding(window, KeyMod.CtrlCmd | KeyCode.KeyS);
expect(save).toHaveBeenCalled();
dispatchPointerBinding(canvas, MouseButton.Left, 'down');Also exported: dispatchKeyPart, dispatchKeybindingString, keyCodeToDomCode.
cmdk and kbar render the palette UI, kilid handles global shortcuts to open/close it and app-wide bindings (⌘S, etc.) with when guards:
import { KeyMod, KeyCode, keybindings } from '@farskid/kilid';
const keys = keybindings(document, { capture: true });
let paletteOpen = false;
keys.add(KeyMod.CtrlCmd | KeyCode.KeyK, () => {
paletteOpen = true;
setOpen(true);
}, { when: () => !paletteOpen, preventDefault: true });
keys.add(KeyCode.Escape, () => {
paletteOpen = false;
setOpen(false);
}, { when: () => paletteOpen });In React, use useKeybinding(KeyMod.CtrlCmd | KeyCode.KeyK, open, { when: () => !open }) in your root layout. cmdk/kbar own arrow-key navigation inside the open palette.
Radix primitives (shadcn/ui) manage focus traps inside overlays. kilid handles global shortcuts with when guards tied to your open state:
import { useState } from 'react';
import { KeyCode } from '@farskid/kilid';
import { useKeybinding, useParsedKeybinding } from '@farskid/kilid/react';
function AppShell() {
const [commandOpen, setCommandOpen] = useState(false);
useParsedKeybinding('Ctrl+K', () => setCommandOpen(true), {
when: () => !commandOpen,
capture: true,
});
useKeybinding(KeyCode.Escape, () => setCommandOpen(false), {
enabled: commandOpen,
});
return <CommandDialog open={commandOpen} onOpenChange={setCommandOpen} />;
}Gate destructive globals (⌘S, delete) while Dialog / AlertDialog is open. shadcn Command + cmdk owns in-palette navigation; kilid opens it.
@farskid/kilid/react is a separate build entry with react as an optional peer dependency, if you never import it, no React-related code enters your bundle. Hooks are split for tree-shaking: useKeybinding (singles only), useChordKeybinding (chords), useParsedKeybinding (strings + parser), and usePointerBinding.
import { KeyMod, KeyCode, KeyChord, MouseButton } from '@farskid/kilid';
import {
useKeybinding,
useChordKeybinding,
useParsedKeybinding,
usePointerBinding,
} from '@farskid/kilid/react';
function Editor() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useKeybinding(KeyMod.CtrlCmd | KeyCode.KeyS, save); // lean, no chord machinery
useChordKeybinding(
KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyS),
openShortcuts
);
useParsedKeybinding('Ctrl+Shift+P', quickOpen); // pulls in parseKeybinding
usePointerBinding(KeyMod.CtrlCmd | MouseButton.Left, 'click', addToSelection, {
target: canvasRef,
});
return <canvas ref={canvasRef} />;
}Hook options mirror the core API: target (EventTarget or ref, default window), when, enabled, preventDefault, stopPropagation, capture, isMac, chordTimeout (keyboard), and pointerType (pointer hook only). Option parity is enforced by test/adapter-contract.test.ts so future framework adapters stay aligned with the core.
- Latest-ref handlers - inline closures are fine; changing the handler or guard re-registers nothing and costs zero per-render work.
- Structural deps only - bindings re-register only when the encoding, target, kind or flags actually change; inline
pointerTypearrays cause no churn. - Refcounted service sharing - hooks on the same target and the same service options (
capture,isMac, …) share one listener; the last unmount disposes it.
Composables: useKeybinding, useChordKeybinding, useParsedKeybinding, usePointerBinding. Accept Vue refs for target via { value }.
createKeybinding, createChordKeybinding, createParsedKeybinding, createPointerBinding — pass accessor functions for handlers.
bindKeybinding, bindChordKeybinding, bindParsedKeybinding, bindPointerBinding — return cleanup; wrap in $effect.
bindKeybinding / bindPointerBinding for use in afterNextRender or services. Standalone attribute directives are in src/angular/directives.ts (copy into your app).
The hot path for every event: bitwise hash (modifiers + code packed into one int) → one integer-keyed Map.get() → handler call. Zero allocations, matched or not. Dispatch cost is flat with respect to the number of registered bindings (~2.5M dispatches/sec in benchmarks, 10 vs 500 bindings within 10%).
| Bundle scenario | Minified | Gzipped |
|---|---|---|
keybindings only (no chords) |
3.3 KB | 1.6 KB |
chordKeybindings |
3.7 KB | 1.8 KB |
| Keyboard + pointer | 5.1 KB | 2.3 KB |
| Everything incl. parse/format | 7.8 KB | 3.3 KB |
React: useKeybinding only |
4.5 KB | 2.1 KB |
React: useKeybinding + usePointerBinding |
7.0 KB | 2.9 KB |
| React: all hooks | 9.1 KB | 3.7 KB |
Sizes are enforced in CI with per-scenario budgets and reported as a comment on every pull request. Size-oriented design: factories instead of classes (state minifies to single-letter closure variables), the KeyCode table generated at runtime from packed strings (typed statically via a template-literal union), no reverse enum mappings, no defensive throws, and every convenience layer in its own tree-shakeable module.
Run npm run bench for numbers.
npm install
npm test # unit tests (vitest + happy-dom)
npm run test:browser # smoke tests (Playwright + Chromium)
npm run bench # dispatch benchmarks
npm run size # bundle-size scenarios → scripts/size-scenarios/.out/
npm run build # tsup -> dist (esm + cjs + d.ts)The landing page lives in docs/ and deploys to GitHub Pages on every push to main that touches docs/**.
See CONTRIBUTING.md for setup, PR workflow, and CI expectations. Maintainers: RELEASING.md.
MIT