Binding keyboard shortcuts in the browser sounds trivial until you actually do it. You listen for keydown, then you have to normalize Cmd on macOS against Ctrl on Windows and Linux, track multi-key sequences like Gmail's g i, time out those sequences, juggle event.key versus event.code for international keyboards, and somehow avoid firing a shortcut while someone is typing into a text field. The older libraries solved this by bundling a large stateful global into your app. tinykeys does the same job in about a kilobyte, with zero dependencies and a modern declarative API.
Written by Jamie Kyle, tinykeys hands you a single function: give it a target element and an object mapping keybinding strings to callbacks, and it returns an unsubscribe function. That is the whole mental model. It powers command palettes, rich-text editor shortcuts, keyboard-driven navigation, power-user dashboards, and the occasional Konami-code easter egg, and it does so without caring whether you use React, Vue, Angular, or no framework at all.
Why It Earns Its Place in Your Bundle
The headline number is the size. The minified and gzipped UMD build clocks in at roughly 1.1 KB, which makes it one of the smallest keybinding libraries you can find, and it ships with no runtime dependencies whatsoever. But small only matters if the ergonomics are good, and here they are excellent.
- Cross-platform modifiers out of the box. The
$modtoken resolves to Cmd on macOS and Ctrl everywhere else, so you write the binding once and it does the right thing for every user. - Declarative bindings. You describe shortcuts as plain strings in an object. No imperative
registerandunregisterdance. - Sequences and combos. Press keys together (
Meta+Shift+D) or in order (g i), including chorded VS Code-style sequences. - Regex matching. Match a whole class of keys with one binding, like
$mod+([0-9]). - Framework-agnostic and TypeScript-first. It operates on plain DOM
addEventListener, ships ESM, CJS, and UMD builds, and includes full type definitions. - Returns its own cleanup function. The unsubscribe function it hands back is exactly what a React
useEffectcleanup wants.
Compared to the classics, tinykeys is dramatically smaller than hotkeys-js and still meaningfully smaller than the venerable but unmaintained mousetrap. The trade-off is that it is deliberately minimal: there are no built-in scopes, no enable and disable groups, and no official React hook. The library expects you to compose that thin layer yourself, which, given how small the surface area is, takes about one line.
Getting It Into Your Project
Installation is the usual one-liner.
npm install tinykeys
yarn add tinykeys
If you would rather skip the build step entirely, the UMD bundle is available from a CDN at https://unpkg.com/tinykeys@4.0.0/dist/tinykeys.umd.js. For everything else, reach for the named ESM import.
Your First Shortcuts
The core export is tinykeys. You pass it a target (usually window), an object of bindings, and you get back an unsubscribe function.
import { tinykeys } from "tinykeys"
const unsubscribe = tinykeys(window, {
"Shift+D": () => {
console.log("Shift and D were pressed together")
},
"y e e t": () => {
console.log("y, e, e, t were pressed in order")
},
"$mod+([0-9])": (event) => {
event.preventDefault()
console.log(`Either Control or Meta plus ${event.key} was pressed`)
},
})
// Later, when you no longer need the bindings:
unsubscribe()
Three things are happening here. The first binding is a combo, fired when Shift and D are held at once. The second is a sequence, fired when those four keys are pressed in order within the timeout window. The third uses $mod so it matches Cmd on Mac and Ctrl elsewhere, plus a regex group that matches any digit and exposes the matched key on event.key. When you are done, calling unsubscribe() removes the listeners cleanly.
Speaking the Keybinding Language
The keybinding string is the heart of the library, so it is worth learning the grammar.
A single key matches either KeyboardEvent.key or KeyboardEvent.code, case-insensitively, so "d", "KeyD", "Enter", and "?" all work. To require modifiers pressed at the same time, join them with + using the names Control, Meta, Shift, Alt, and AltGraph.
tinykeys(window, {
"Control+d": handleDelete,
"Meta+Shift+D": handleDuplicate,
"$mod+s": handleSave,
})
That $mod is the single most useful token in the library. Instead of branching on the user's platform, you write "$mod+s" once and let tinykeys map it to Cmd or Ctrl as appropriate.
Sequences are separated by spaces, and each press must land within the timeout. This is how you build Gmail-style navigation or the obligatory Konami code.
tinykeys(window, {
"g i": () => navigate("/inbox"),
"g s": () => navigate("/starred"),
"$mod+k $mod+1": () => focusPanelOne(),
"ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight b a": () =>
activateCheatMode(),
})
A note on physical layout: for anything beyond US QWERTY, prefer code-based bindings like "KeyB" over "b". The code value reflects the physical key position, while key reflects the character produced, which shifts around on international keyboards.
A Clean React Hook in One Line
Because tinykeys returns its unsubscribe function, the idiomatic React pattern almost writes itself. The function you return from useEffect is the cleanup, and that is precisely what tinykeys hands you.
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
function useGlobalShortcuts() {
useEffect(() => {
return tinykeys(window, {
"$mod+b": formatBold,
"$mod+i": formatItalic,
"$mod+k": openCommandPalette,
})
}, [])
}
There is no official useTinykeys hook, and that is the point: the library is small enough that you write this yourself. The one gotcha to remember is stale closures. If your callbacks depend on changing state or props, either include them in the dependency array (which re-subscribes on every change) or keep the latest handler in a ref so the binding always calls the current version.
Here is a complete command palette toggle that respects that pattern while keeping a stable effect:
import { useEffect, useState } from "react"
import { tinykeys } from "tinykeys"
function CommandPalette() {
const [open, setOpen] = useState(false)
useEffect(() => {
return tinykeys(window, {
"$mod+k": (event) => {
event.preventDefault()
setOpen((current) => !current)
},
Escape: () => setOpen(false),
})
}, [])
return open ? <PaletteOverlay onClose={() => setOpen(false)} /> : null
}
Controlling Listeners and Ignored Targets
Sometimes window is the wrong target. When you want shortcuts scoped to a specific element, or you want to manage the listener yourself, reach for createKeybindingsHandler. It returns a bare event handler you attach and detach by hand.
import { createKeybindingsHandler } from "tinykeys"
const handler = createKeybindingsHandler({
"$mod+Enter": submitForm,
})
editorEl.addEventListener("keydown", handler)
// editorEl.removeEventListener("keydown", handler) to clean up
The tinykeys function also accepts a third options argument that controls the event type, the sequence timeout, and which events to skip.
tinykeys(window, bindings, {
event: "keydown", // or "keyup"; default is "keydown"
timeout: 1000, // ms allowed between presses in a sequence
ignore: (event) => false, // custom filter for events to skip
})
The timeout governs how long a sequence has between presses before it resets. Anywhere below roughly 300ms tends to frustrate users, so the 1000ms default is a sensible starting point.
The v4 Shift You Should Know About
Version 4, released in May 2026, is a modern rewrite of the matching behavior, and one change deserves a callout because it can silently alter how your app feels. By default, tinykeys now ignores keydown events whose target is an <input>, <textarea>, <select>, or [contenteditable] element. In other words, your global shortcuts no longer fire while the user is typing into a form field, which is almost always what you want.
If you depended on the old behavior, where shortcuts fired everywhere including inside inputs, you can restore it explicitly with a permissive ignore function.
tinykeys(window, bindings, {
ignore: () => false, // never ignore, fire even inside form fields
})
Version 4 also brought optional modifiers, where wrapping a modifier in brackets makes it match with or without that key. This is handy for shortcuts that should be forgiving about Shift.
tinykeys(window, {
"[Shift]+?": () => openShortcutHelp(),
"Control+[Shift]+D": duplicate,
})
Finally, v4 introduced declaration-order priority: when two bindings could both match, the first one you declare wins. If a completed sequence could also trigger an earlier binding, tinykeys logs a conflict warning rather than firing both. The practical rule is to order your bindings most-specific-first and to read those warnings when they show up.
Rendering Shortcut Hints
A nice touch for polished UIs is showing users which keys to press. The parseKeybinding export turns a binding string into a structured array, which you can map into styled <kbd> chips.
import { parseKeybinding } from "tinykeys"
const parsed = parseKeybinding("$mod+k")
// Use the parsed structure to render <kbd>⌘</kbd><kbd>K</kbd>
Pairing this with your binding definitions means your help overlay and your actual shortcuts never drift out of sync, because they both come from the same source of truth.
When tinykeys Is the Right Call
Reach for tinykeys when you want a modern, framework-neutral keyboard core that stays out of your way and out of your bundle. It shines for command palettes, editor shortcuts, keyboard-driven navigation, and any power-user surface where shortcuts are a feature rather than an afterthought. The $mod token alone removes a whole category of platform bugs, and the unsubscribe-as-cleanup design makes it feel native inside React effects.
If you genuinely need batteries like built-in scopes, pause and resume groups, or a fully featured React hook out of the box, a larger library such as react-hotkeys-hook will hand you more for the extra bytes. But if you want the smallest, cleanest, most modern keybinding primitive available, and you are happy to write a one-line useEffect to wire it up, tinykeys is hard to beat. A kilobyte rarely buys this much keyboard power.