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 1.6 KB gzip with chords 1.8 KB gzip 0 dependencies ~2.5M dispatches/sec tree-shakeable
$ npm install @farskid/kilid

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…

Why kilid

Compact numeric encoding

Bindings are single numbers: KeyMod.CtrlCmd | KeyCode.KeyS, chords via KeyChord(...) — one integer per binding, resolved at registration.

Zero-allocation dispatch

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.

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.CtrlCmd means Cmd on macOS and Ctrl elsewhere. One binding, correct everywhere, resolved once at registration.

Layout-independent

Bindings match physical keys via KeyboardEvent.code, with an event.key fallback 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.

How kilid compares

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).

Quick start

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

API reference

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.

Encoding: KeyMod, KeyCode, MouseButton, KeyChord

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
ExportMeaning
KeyMod.CtrlCmdCmd on macOS, Ctrl on Windows/Linux
KeyMod.WinCtrlCtrl on macOS, Win/Meta on Windows/Linux
KeyMod.Shift, KeyMod.AltShift; 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

Strings: 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).

Recipes

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.

Capture phase

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);

One-shot binding

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);
});

Event delegation (bubbling)

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
});

Testing (Vitest / Playwright)

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.

Command palette (cmdk / kbar)

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 UI / shadcn (dialogs, menus, command)

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.

Framework adapters

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.

React — @farskid/kilid/react

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);
  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.

Vue — @farskid/kilid/vue

import { 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 });
  },
};

Solid — @farskid/kilid/solid

import { KeyMod, KeyCode } from '@farskid/kilid';
import { createKeybinding, createPointerBinding } from '@farskid/kilid/solid';

createKeybinding(KeyMod.CtrlCmd | KeyCode.KeyS, () => save);
createPointerBinding(MouseButton.Left, 'down', () => onDown);

Svelte — @farskid/kilid/svelte

Imperative 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));

Angular — @farskid/kilid/angular

import { 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.

Performance & size

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 scenarioMinifiedGzipped
keybindings only (no chords)3.3 KB1.6 KB
chordKeybindings3.7 KB1.8 KB
Keyboard + pointer5.1 KB2.3 KB
Everything incl. parse/format7.8 KB3.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.