Skip to content

farskid/kilid

Repository files navigation

kilid

CI

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.

Docs & live demo →

Core (no chords) 1.6 KB gzip
With chords 1.8 KB gzip
Dependencies 0
Dispatch throughput ~2.5M/sec
npm install @farskid/kilid

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

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

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

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:

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.

Command palette (cmdk / kbar)

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

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.

React adapter

@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 pointerType arrays 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.

Vue — @farskid/kilid/vue

Composables: useKeybinding, useChordKeybinding, useParsedKeybinding, usePointerBinding. Accept Vue refs for target via { value }.

Solid — @farskid/kilid/solid

createKeybinding, createChordKeybinding, createParsedKeybinding, createPointerBinding — pass accessor functions for handlers.

Svelte — @farskid/kilid/svelte

bindKeybinding, bindChordKeybinding, bindParsedKeybinding, bindPointerBinding — return cleanup; wrap in $effect.

Angular — @farskid/kilid/angular

bindKeybinding / bindPointerBinding for use in afterNextRender or services. Standalone attribute directives are in src/angular/directives.ts (copy into your app).

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

Development

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.

License

MIT

About

Fast and efficient TypeScript keyboard, mouse and pointer management

Topics

Resources

License

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors