A zero-dependency TypeScript library for DOM keyboard, mouse and pointer event bindings — packed into a couple of kilobytes with zero-allocation dispatch.
Live demo — chords registered with chordKeybindings
Press the first key and the node lights up while a timer drains — the library is waiting for the second key. Complete the sequence before it runs out. On a phone, tap the nodes in order.
press a sequence…
loading kilid bindings…
Bindings are single numbers: KeyMod.CtrlCmd | KeyCode.KeyS, chords via KeyChord(...) — one integer per binding, resolved at registration.
Every event reduces to one integer hash and one Map lookup. No strings, objects or closures are created on the hot path — flat cost at any binding count.
Chords, string parsing, the pointer service and the React adapter are separate modules. A Cmd+S-only app ships 1.6 KB.
KeyMod.CtrlCmd means Cmd on macOS and Ctrl elsewhere. One binding, correct everywhere, resolved once at registration.
Bindings match physical keys via KeyboardEvent.code, with an event.key fallback for exotic keyboards.
Pointer events subsume mouse. A single service covers down/move/click/wheel with the same modifier encoding, plus pen/touch filters.
kilid targets apps that need keyboard and pointer bindings in one tiny, tree-shakeable package. Most alternatives cover keyboard only — or ship a much larger runtime. Competitor bundle sizes are approximate (minified + gzip, typical single-import usage on Bundlephobia / esbuild); kilid numbers are from this repo's CI size scenarios.
| 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+ |
When kilid fits: editors, canvases, dashboards — anywhere you want
Cmd+Click, pen/touch filters, and Ctrl+K Ctrl+S chords beside
plain Cmd+S, without ad-hoc addEventListener sprawl.
When something else fits: keyboard-only hotkeys in a React app
(react-hotkeys-hook, tinykeys); a command palette UI
(cmdk — pair it with kilid for global shortcuts, 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 listener
Everything 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).
keybindings(target, options?)
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();
chordKeybindings(target, options?)
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 part
pointerBindings(target, options?)
One 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.
KeyMod, KeyCode, MouseButton, KeyChordA 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 |
parseKeybinding / formatKeybinding
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 — no hand-rolled KeyboardEvent objects
with wrong modifier flags.
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();
const el = document.getElementById('canvas')!;
dispatchPointerBinding(el, MouseButton.Left, 'down');
Also exported: dispatchKeyPart (single key of a chord),
dispatchKeybindingString (pulls in the parser), and
keyCodeToDomCode for custom fixtures.
cmdk and
kbar render the palette UI and handle
in-palette keyboard navigation — but global shortcuts to open the palette
(⌘K, ⌘⇧P) belong on document with kilid.
Use when so bindings don't fire while the palette is open.
import { KeyMod, KeyCode, keybindings } from '@farskid/kilid';
import { Command } from 'cmdk'; // or KBarProvider from kbar
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,
});
// cmdk handles arrow keys / Enter inside the open dialog.
// kilid handles global open/close and app shortcuts (⌘S, etc.) with when guards.
React: register in a root layout effect or
useKeybinding(KeyMod.CtrlCmd | KeyCode.KeyK, open, { when: () => !open }).
kbar: same pattern — kilid opens KBarProvider,
kbar owns the action list.
Radix primitives (used by shadcn/ui) manage focus
traps and roving tabindex inside open overlays. kilid sits outside that layer:
global app shortcuts on document, with when guards tied to
your open-state (Radix open prop or shadcn useState).
import { KeyMod, KeyCode, keybindings } from '@farskid/kilid';
// shadcn: Dialog, DropdownMenu, Command (cmdk) — all Radix under the hood
const keys = keybindings(document, { capture: true });
keys.add(KeyMod.CtrlCmd | KeyCode.KeyS, save, {
when: () => !dialogOpen && !dropdownOpen,
});
keys.add(KeyCode.Escape, closeTopmostOverlay, {
when: () => dialogOpen || dropdownOpen,
preventDefault: false, // let Radix also handle Escape if needed
});
// shadcn Command palette: ⌘K opens Dialog + Command; kilid registers the shortcut,
// cmdk handles filtering. Pointer: usePointerBinding on a custom canvas beside shadcn UI.
// React + shadcn example
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} />
{/* Radix Dialog traps focus; kilid shortcuts are gated by commandOpen */}
</>
);
}
Tip: disable destructive globals (⌘S, delete shortcuts)
while a Radix Dialog or AlertDialog is open. For
DropdownMenu, prefer kilid on the trigger's container for pointer
shortcuts; menu item selection stays Radix's keyboard handling.
Each adapter is a separate entry point with an optional peer dependency — import only
the framework you use. All adapters share the same option contract
(capture, when, enabled, …) enforced in
test/adapter-contract.test.ts.
@farskid/kilid/reactimport { 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);
useChordKeybinding(
KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyS),
openShortcuts
);
useParsedKeybinding('Ctrl+Shift+P', quickOpen);
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.
pointerType arrays cause no churn.capture, isMac, …) share one listener; the last unmount disposes it.@farskid/kilid/vueimport { KeyMod, KeyCode } from '@farskid/kilid';
import { useKeybinding, usePointerBinding } from '@farskid/kilid/vue';
export default {
setup() {
const canvas = ref<HTMLCanvasElement | null>(null);
useKeybinding(KeyMod.CtrlCmd | KeyCode.KeyS, save);
usePointerBinding(MouseButton.Left, 'down', onDown, { target: canvas });
},
};
@farskid/kilid/solidimport { KeyMod, KeyCode } from '@farskid/kilid';
import { createKeybinding, createPointerBinding } from '@farskid/kilid/solid';
createKeybinding(KeyMod.CtrlCmd | KeyCode.KeyS, () => save);
createPointerBinding(MouseButton.Left, 'down', () => onDown);
@farskid/kilid/svelteImperative bind* helpers — wrap in Svelte 5 $effect for lifecycle:
import { KeyMod, KeyCode } from '@farskid/kilid';
import { bindKeybinding } from '@farskid/kilid/svelte';
$effect(() => bindKeybinding(window, KeyMod.CtrlCmd | KeyCode.KeyS, save));
@farskid/kilid/angularimport { afterNextRender } from '@angular/core';
import { KeyMod, KeyCode } from '@farskid/kilid';
import { bindKeybinding } from '@farskid/kilid/angular';
afterNextRender(() => {
bindKeybinding(window, KeyMod.CtrlCmd | KeyCode.KeyS, () => this.save());
});
Standalone attribute directives (KilidKeybindingDirective) live in
src/angular/directives.ts for copy-into-app use — esbuild does not
emit decorator metadata in the published bundle yet.
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 |
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.