Library / SDK
jamiebuilds/tinykeys avatar
jamiebuilds/tinykeys

tinykeys: the size claim differs between the repository and the README

A tiny (~650 B) & modern library for keybindings.

4,100 stars88 forksTypeScriptMIT

At a glance

What is it?
A keyboard shortcut library read through its own manifest and syntax rules: two different size claims in two places, a check script that calls a lint script which does not exist, a thousand millisecond window that defines every sequence, and first match wins silently.
Who is it for?
tinykeys fits a web application that wants browser shortcuts with a readable binding syntax, including sequences like a vim style chord and a cross platform modifier, and it does not fit a codebase pinned to Node below 22 or a build that expects a CommonJS default export without using the exports map.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 10 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 4, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Two size claims for the same library

The size of this library is quoted in three places and they do not agree with each other. The repository description says it is a tiny library of about 650 bytes. The README header and the description field in the package manifest both say about 1KB. The two byte figures differ by more than half, and neither states what is being measured: the minified bundle, the gzipped bundle, or the published ES module with its type declarations. Since the package publishes four separate artefacts, a size claim is meaningless without saying which file it refers to, and the disagreement between the repository blurb and the manifest suggests the blurb was written at a different time or against a different build. For anyone choosing this library partly on size, the honest position is that the number to check yourself is the one in your own bundler after minification, not either figure in the metadata. What is consistent everywhere is the claim that there are no runtime dependencies at all: the manifest has no dependencies key, only devDependencies for the build.

The check script calls a lint script that is not defined

The manifest's script list has an inconsistency that breaks the obvious command. The check script is composed of three steps:

code
"check": "npm run -s check:types && npm run -s lint && npm run -s check:format"

The first and third steps exist as check:types and check:format, and both are defined: one runs tsc --noEmit, the other runs prettier --check. The middle step calls a script named lint, and there is no script with that name. The linting script in the list is named check:lint and runs eslint .. So running the composite check command fails on a missing script name rather than on a lint error, which is an easy thing to misdiagnose as an environment problem. It also means linting is not part of whatever pipeline invokes check, since the failure happens before any file is examined. The rest of the script list is conventional and shows the shape of the toolchain: the build is tsdown, the example site is built and served by vite with a base path, tests run through vitest, and prepublishOnly runs the build so a published artefact cannot be stale.

Two entry points and four published files

The manifest is a good description of what a bundler will do with this package. It declares itself an ES module with sideEffects false, which is the field that lets a bundler drop the import entirely when nothing is used from it, and that claim only holds because the library attaches no global side effect. The source is a single file at src/tinykeys.ts, which matches the primary language of the repository. Four artefacts come out of the build: a CommonJS file as main, an ES module as module, a UMD build for unpkg, and type declarations. The exports map is the modern part, and it splits by condition, giving each of import and require its own type declaration file, one .d.mts for the ES module path and one .d.cts for the CommonJS path. So a TypeScript project using import resolution and one using require resolution get different declaration files with the same API. The files array publishes dist and nothing else, so the source and the tests are not installed, and the unpkg field points at the UMD build that exposes the library on the window as window.tinykeys.

Sequences are time bound and the first match wins

A binding is a sequence of presses, and the sequence semantics are what separate this from a single key map. Several presses in a row form one binding, so "g i" is a single chord for go to inbox and "g a" for go to archive, and the example goes further with an eight press sequence standing in for the Konami code. Each press in a sequence may carry its own modifiers, as in $mod+K $mod+1 for toggling a level. The timing rule is one number: each press must happen within 1000 milliseconds of the last. That constant is the entire definition of how long you have, and it is fixed rather than configurable, so a binding that needs a longer window cannot have one. Priorities are resolved by declaration order rather than by specificity. If two bindings match the same event, only the first fires, and the documentation says this rarely comes up except in one specific case: when you define bindings against both event.key and event.code to support more keyboard layouts, as with "$mod+b" and "$mod+KeyB" together. There only the first one triggers, so the key and code bindings for the same physical key are competing rather than complementary.

$mod, optional modifiers, and a regex escape hatch that excludes modifiers

Three pieces of syntax cover most bindings. A single press matches event.code or event.key case insensitively, so "d" matches the key and "KeyD" matches the code, and modifiers are matched against the modifier state API, giving combinations like "Control+d", "Meta+Shift+D" or "Alt+KeyD". The cross platform alias $mod resolves to Meta on Mac and to Control on Windows and Linux, which is what makes one binding work in both places without a platform check. Optional modifiers are written in brackets, as in "[Shift]+?" for shift with an optional question mark or "Control+[Shift]+D". The escape hatch is parentheses, which take a case insensitive regular expression to match several keys at once, with the equivalent pattern shown as a regex literal. The limitation is explicit: this does not work for modifiers. So a single key can be matched by a pattern and a modifier cannot, which is a reasonable boundary and still worth knowing before you write a binding that tries to match a whole chord with one expression.

AltGraph is aliased, and international layouts are the reason

One alias exists and it exists for layouts rather than for convenience. On Windows, on many non US standard layouts, there is a key named Alt Gr or AltGraph in the browser, and in some browsers pressing Control and Alt together reports AltGraph rather than the two modifiers. On macOS the Alt or Option key will sometimes be reported as AltGraph instead of Alt. The library aliases that so one binding catches all of the reported forms, and the documented example writes bindings for Control+Alt+KeyS and $mod+Alt+KeyS with comments enumerating what each one matches on macOS and on Windows. The advice attached to it is the part to remember: since the purpose of Alt Gr is to type alternate characters, you will often want to use event.code such as KeyS rather than event.key such as S. The documentation makes the same point about the code and key table, where a footnote says some keys share a code because they appear on the same physical key, and international keyboards with different layouts are affected. A key logger on the demo site is offered for anything the table does not cover.

In React, the handler identity is part of the API contract

The React guidance is one sentence and it is load bearing: if you use the library inside a component, use the returned unsubscribe function. The example shows why, because the effect returns what tinykeys returns:

js
useEffect(() => {
  return tinykeys(window, {
    "$mod+b": formatBold,
    "$mod+i": formatItalic,
  })
}, [])

The empty dependency array is only correct because unsubscribe is returned, so without it every render would add another listener. The example also uses useEffectEvent for the two callbacks, which is the React hook that keeps a handler identity stable across renders, and that matters here for the same reason. The alternative API exists for the case where you want the handler without the listener: createKeybindingsHandler takes the binding map and returns a function you register yourself with window.addEventListener, as the second example does. The keywords in the manifest list mousetrap alongside react, vue, angular and ember, so the author is positioning this against an older hotkey library rather than against other modern ones.

Editorial conclusion

tinykeys fits a web application that wants browser shortcuts with a readable binding syntax, including sequences like a vim style chord and a cross platform modifier, and it does not fit a codebase pinned to Node below 22 or a build that expects a CommonJS default export without using the exports map. Before depending on it, note the two facts that are easy to get wrong: the repository description claims about 650 bytes while the README and the manifest both say about 1KB, and the manifest's check script invokes a lint script that is not defined. The library is small, the surface is two functions, and the syntax is documented in more detail than the size would suggest.

Frequently asked questions

How do I install tinykeys?

Run npm install --save tinykeys. A CDN version is also available on unpkg, where the UMD build exposes the library as window.tinykeys instead of an import. The manifest declares Node 22 or newer as the engine requirement and publishes four artefacts: a CommonJS file, an ES module, a UMD build, and type declarations.

How do I write a cross platform shortcut with tinykeys?

Use the $mod modifier, which resolves to Meta on Mac and to Control on Windows and Linux, so $mod+Shift+D covers both. Modifiers are matched against the modifier state API, and a modifier can be made optional by wrapping it in brackets, as in Control+[Shift]+D.

How long do I have between the keys in a tinykeys sequence?

Each press in a sequence must be pressed within 1000 milliseconds of the last one. That value is stated as the rule for sequences such as g i, and the timing window is fixed rather than configurable in what the README describes.

How do I remove tinykeys listeners in a React component?

Use the unsubscribe function returned by tinykeys and return it from your effect, as the README shows. The library also exposes createKeybindingsHandler, which takes the binding map and returns a handler you register yourself with an event listener if you would rather own the registration.

Why does my tinykeys binding for both event.key and event.code not fire?

The order of your keybindings matters and only the first match fires. If two bindings would match the same event, the first one wins, which mainly comes up when you define bindings for both event.key and event.code to support more keyboard layouts, as with $mod+b alongside $mod+KeyB.

Official sources

  1. Issues
  2. jamiebuilds/tinykeys on GitHub
  3. License: MIT
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/jamiebuilds-tinykeys.svg)](https://hysenlabs.com/projects/jamiebuilds-tinykeys)