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