npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@inklu/keys

v3.1.0

Published

A command system for the web: typed commands, portable keybindings, scopes, sequences, conflict detection, rebinding and platform-aware display.

Readme

@inklu/keys

@inklu/keys organises an app's keys and respects its boundaries. Every shortcut is a named command declared in one place, and exactly one command runs per keypress: the right one for where the user is.

Three ideas, learned as you need them:

  • Command: something the app can do, with a title, default keys and the scope it is live in.
  • Handler: what the command does, attached from the component that owns the state.
  • Keymap: the keys, kept apart from the definition so users can rebind them.

It has no dependencies, and listens with one keydown listener however many commands exist. What an app does not use, it does not ship: persistence and recording a shortcut are imports of their own, and the registry does not pull them in.

Docs: inklu.saddy.me/keys · For agents: llms.txt · Agent skills: npx skills add nkurunziza-saddy/inklu --skill inklu-keys for the API, and --skill inklu-keys-practices for choosing and showing shortcuts well

pnpm add @inklu/keys

1. Define Commands

Declare every command in one module. Ids come from the object keys, so commands.run("doc.save") autocompletes and a typo is a type error.

A chord is written modifiers first, then one key: "Mod+Shift+S". The modifiers are Mod, Control, Alt, Shift and Meta, in that order, and Mod is Command on macOS and Control elsewhere. The key is the character or the name KeyboardEvent.key gives it ("K", "?", "Escape", "ArrowUp"). Chords are typed, so a misspelling is a compile error. An array of chords means alternatives; a sequence is seq("G", "I"), and each step must come within a second of the one before, or within the registry's sequenceTimeout.

// commands.ts
import { createCommands, seq } from "@inklu/keys";

export const commands = createCommands({
	"palette.open": { title: "Open command palette", keys: "Mod+K" },
	"doc.save": { title: "Save document", keys: "Mod+S" },
	"nav.inbox": {
		title: "Go to inbox",
		keys: seq("G", "I"), // press G, then I
		// Needs nothing a component owns, so it can live here.
		run: () => router.navigate("/inbox"),
	},
});

A chord names what the key types, so Mod+S matches the key that types s on the user's layout. Where a layout types no Latin letter on a letter key (Cyrillic, Greek), or on the digit row, the key at that position on a US layout matches too.

Keys that differ by platform

Mod covers most shortcuts. Where the platforms' own conventions differ, give each its keys: mac is for Apple keyboards (macOS, iPadOS, iOS), other for the rest. A side left out is unbound there.

createCommands({
	"edit.redo": { title: "Redo", keys: { mac: "Mod+Shift+Z", other: ["Mod+Y", "Mod+Shift+Z"] } },
	"nav.back": { title: "Back", keys: { mac: "Mod+[", other: "Alt+ArrowLeft" } },
	"doc.new": { title: "New document", keys: { mac: "Mod+Alt+N", other: "Alt+N" } },
});

Some keys are not an app's to bind, and the registry warns about them in development: keys the browser or operating system keeps (Mod+N, Mod+T, Mod+W and their Shift forms, Control+Tab, Mod+Q on a Mac, Alt+F4 and anything holding Meta elsewhere), which the page mostly never receives, so doc.new above is not Mod+N; and Control+Alt with a character key, which is AltGr on many Windows and Linux layouts and the VoiceOver modifier on a Mac, so Mod+Alt+N is fine on a Mac and not elsewhere. Browser features a page can take over (Mod+F, Mod+P, Mod+L, Mod+R) are left to your judgement.

Commands generated from data are typed when the generated object's keys are: give an Object.fromEntries result its key type (as Record<\go.${Slug}`, CommandDefinition>) before spreading it in, or name every id yourself with createCommands({ … })`.

2. React Setup

Pass the registry explicitly to all hooks. There is no Context provider.

import { useMountCommands, useShortcut } from "@inklu/keys/react";
import { commands } from "./commands";

useMountCommands(commands); // once, near the root: starts listening in an effect

function ShortcutButton() {
	// Live state for the primary binding: `display` to read, `tokens` one string per key
	const { display, aria } = useShortcut(commands, "palette.open");
	return (
		<button onClick={() => commands.run("palette.open")} aria-keyshortcuts={aria || undefined}>
			Open Palette <kbd>{display}</kbd>
		</button>
	);
}

Handlers

A command runs when its scope is open and it has a handler. A definition's own run is one; useCommandHandler attaches another for as long as a component is mounted, and the latest closure is always the one called.

// The last argument switches the handler off: with nothing to save, Mod+S is not ours.
useCommandHandler(commands, "doc.save", save, dirty);
  • A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it. Outside React, commands.handle(id, run) does the same and returns a function that detaches.
  • The most recently mounted handler wins. Unmounting hands the command back to the one before it, or to the definition's run. "Most recently" is the order React runs effects in, so of a parent and a child mounting in the same render, the parent's comes last and wins.
  • Keep a handler with its state in code that is already loaded: inside a lazily loaded component it leaves the key to the browser until its chunk arrives.

Switch a command off by its handler (enabled, or detaching) rather than with an early return inside it: only then does the key fall through to another command or the browser.

3. Scopes

Use scopes to isolate commands when modals or dialogs are open.

// In the dialog: while open, the keys of commands outside this scope are silenced.
useScope(commands, "dialog", isOpen, { exclusive: true });

exclusive is a barrier: it silences every key beneath it. To claim only a few keys, drop it and give those commands a scope instead — see below.

A barrier claims the keyboard, not the command: commands.run(id) still works beneath one, so a palette can run what it lists while its own scope is open. A direct run only needs the command's own scope to be open. To make a button obey the same gate as its key, read reachable from useCommand(commands, id).

When two commands share a key

Exactly one command runs per keypress. Among the commands that could take it, the winner is:

  1. A sequence being finished, over a chord on the same key.
  2. The command whose scope was opened most recently. Scopes track the surfaces on screen, so the dialog on top claims Escape before the panel beneath it, and any scoped command beats a global one. "*" counts as global here — it means "survives barriers", not "belongs to a region of the UI". With useScope, "most recently" is the order React runs effects in: of a parent and a child that open in the same render, the child is pushed first.
  3. Failing that, the one defined later.

A command with no handler is never a candidate, so it steps aside rather than swallowing the keypress. This is how Escape closes the dialog when one is open and the panel otherwise, without making the dialog exclusive. Two commands sharing a chord in the same scope can only be told apart by which has a handler, so that case warns in development.

4. Rebinding

Overrides are layered over defaults.

commands.keymap.set("palette.open", "Mod+P"); // remap
commands.keymap.set("palette.open", ["Mod+P", "Mod+K"]); // two keys for it
commands.keymap.set("palette.open", null); // unbind
commands.keymap.reset("palette.open"); // reset to default

set replaces the command's keys outright; setting exactly the default forgets the override instead.

To rebind a command you did not define — one a package shipped — pass bindings: { "palette.open": "Mod+P", "doc.publish": null } when creating the registry (null ships it unbound). An unknown id is a compile error, and reset returns here rather than to the definition's keys. These are defaults, applied before persisted overrides load, so a user's own rebinding still wins.

In React, useRebind is a capture flow; createRebinder is the same without React:

const rebind = useRebind(commands, "palette.open"); // { sequence: true } records steps until Enter
<button onClick={rebind.recording ? rebind.cancel : rebind.start}>
	{rebind.recording ? "Press keys…" : rebind.display}
</button>;

const rebinder = createRebinder(commands, "palette.open", {
	rejectConflicts: true, // don't apply a binding another command uses
	onRecord: (binding, conflicts) => {}, // `binding` is null for Backspace, which unbinds
});
rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
rebinder.start(); // Escape cancels, Backspace unbinds

While recording, every command is silenced — including "*" ones — so the keys being recorded never run anything. The rebinder applies whatever is recorded: keys the browser keeps are warned about in development, not refused. It is built on commands.capture(onKey), which hands every keypress to onKey(event, chord) until the function it returns is called; use it directly for a recorder of your own.

5. Conflicts

commands.conflicts(binding, { scope, ignore }) lists the commands in overlapping scopes whose keys stand in the way of binding. Each result has the other command's id, its binding and display, and a kind:

  • "exact" — the same keys. Only one command runs (see above).
  • "prefix" — one binding starts the other, like G against seq("G", "I"). These cannot be arbitrated: pressing G runs the chord, and continuing with I runs the sequence too.

A settings row shows them as Also used by ${commands.get(conflict.id)?.title}. In development, both are also reported with console.warn when the registry is created, for the defaults on both kinds of platform, along with keys the browser or operating system keeps. The check is dropped from production builds.

6. Persistence and SSR

import { createCommands, webStorage } from "@inklu/keys";

createCommands(definitions, { persist: webStorage("my-app.keys") });

webStorage(key, storage?) keeps the user's overrides in localStorage. It is an import, so a registry created without it carries no storage code. Pass a second argument to keep them elsewhere: a function returning the storage, as in webStorage("my-app.keys", () => sessionStorage); any object with getItem, setItem and removeItem will do, and the function is only called in the browser.

Only user overrides are stored — never defaults. To move them between devices yourself, exportKeymap(commands) returns them as { version, overrides } and importKeymap(commands, data) puts them back and saves them. Data in a version this release does not know is ignored instead of misread.

For a store that is not Web Storage, persist takes any object shaped like KeymapStore: read() returns a Map of bindings by command id (or null when it cannot) and write(overrides) takes one, both synchronous. For something asynchronous, such as a user's account, use exportKeymap and importKeymap instead.

The registry is safe to create at module scope and render on the server. Persisted overrides load on the first mount(), not at creation, so the server render and the client's first render match; subscribers (and every hook) are notified when they arrive, and keymap.set/reset before mount loads them first. Before mount, shortcuts render in the non-Apple form (Ctrl+K); after mount they switch to the detected platform (⌘K), and { mac, other } keys to its side. Pass platform to pin it. Nothing listens for keys until mount() (or useMountCommands).

7. Inputs and events

While focus is in a text input, textarea, select or contenteditable, or in a custom widget with a text-entry role (textbox, searchbox, combobox, spinbutton, slider), no command fires except one on Escape, so typing and the field's own editing keys are never hijacked. A command that should work there too, such as save on Mod+S, sets ignoreInputs: false. Keydowns that are part of an IME composition never trigger commands.

A command that runs claims its key with preventDefault(), as does a key that is a step on the way through one of its sequences; one that is unhandled, out of scope or outranked leaves the event alone. Propagation is never stopped. A held key runs its command once: auto-repeat is ignored. A handler may be async; nothing awaits it.

commands.whyNotRun(id) explains a command that would not run: "unhandled" (nothing has attached a handler), "out-of-scope" (its scope is not open), "unknown", or null when it would run.

8. Command palette

The registry lists every command with its title, its keys as tokens and whether it is reachable; the app filters and groups that list. What the registry does not hold, such as group headings or the ids to leave out, is a map of your own keyed by command id.

const GROUPS: Partial<Record<CommandId<typeof commands>, string>> = { "doc.find": "Document" };
const words = query.toLowerCase().split(/\s+/).filter(Boolean);

// What can run right now (it has a handler and its scope is open), and matches every word.
const results = useCommands(commands).filter(
	(command) =>
		commands.whyNotRun(command.id) === null &&
		words.every((word) => command.title.toLowerCase().includes(word)),
);

Give palette.open ignoreInputs: false, so Mod+K works from a text field and from the palette's own search box.

9. Without React: data attributes

import { observe } from "@inklu/keys/attributes";

const stop = observe(commands); // or observe(commands, { root: element })
<button data-command="palette.open">Search <kbd data-command-keys="palette.open"></kbd></button>

Clicking an element with data-command runs that command (source: "dom"), unless the element or an ancestor has aria-disabled="true" or another handler already called preventDefault(). Elements with data-command-keys get the shortcut as text, and data-command elements get aria-keyshortcuts; both stay current across rebinds and DOM changes.

10. Recipes

Patterns the pieces above already cover, so none of them needs an API of its own. Which item a shortcut acts on is your state: compute the target once and pass targets.length > 0 as enabled, so with nothing targeted the key falls through, reachable is false for its button, and a palette that lists what can run leaves it out.

A shortcut sheet on ? and Mod+/

"help.shortcuts": { title: "Keyboard shortcuts", keys: ["?", "Mod+/"], scope: "*" },

Build the sheet from useCommands(commands), grouped and searched the way the palette is. When a user turns single-key shortcuts off, ? goes and Mod+/ stays, so the sheet that explains the setting is still reachable.

A hint for a sequence that has started

After G, show what the next key could run. usePendingSequences(commands) lists the sequences waiting for their next step, each with its steps as tokens and how many are typed; it is empty again once one runs, another key is pressed, or the time is up. Without React, read commands.pending() in a commands.subscribe listener.

function SequenceHint() {
	const pending = usePendingSequences(commands);
	if (pending.length === 0) return null;
	return (
		<ul role="status">
			{pending.map(({ id, tokens, typed }) => (
				<li key={id}>
					{tokens.slice(typed).map((token) => (
						<kbd key={token}>{token}</kbd>
					))}{" "}
					{commands.get(id)?.title}
				</li>
			))}
		</ul>
	);
}

A second is the wait most shortcut libraries use, and short for someone reading the list for the first time. Give them longer with createCommands(definitions, { sequenceTimeout: 2500 }): the wait is in milliseconds and applies to every sequence.

A way out of a text field

Nothing fires while the user types, so give them a key that leaves the field. Escape is the one key that fires inside an input:

// "field.leave": { title: "Leave field", keys: "Escape" }
const [editing, setEditing] = useState(false);
useCommandHandler(commands, "field.leave", () => rowRef.current?.focus(), editing);
<input onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} />;

Move focus to the item the field belongs to, not to nothing, so the next key has a target.

API Summary

  • createCommands(definitions, options) - creates the registry
    • options: { bindings, platform, persist, singleKeyShortcuts, sequenceTimeout, onRun }; platform is "mac" or "other"
    • a definition: { title, keys, scope, ignoreInputs, run }
    • commands.run(id), whyNotRun(id), handle(id, run)
    • commands.get(id), list(), subscribe(listener): snapshots with display, tokens, aria, reachable, overridden
    • commands.pushScope(name, { exclusive }), capture(onKey), conflicts(binding, options), mount(), destroy()
    • commands.pending(): the sequences that have started, each { id, tokens, typed }
    • commands.serverList(): the list as the server rendered it, for a framework's hydration snapshot
    • commands.keymap: get, set, reset
    • commands.setSingleKeyShortcuts(enabled), commands.singleKeyShortcuts
  • webStorage(key, storage?) - the persist option: Web Storage
  • exportKeymap(commands), importKeymap(commands, data) - user overrides as JSON-safe data
  • createRebinder(commands, id, options) - framework-agnostic shortcut recording
  • observe(commands, { root }) from @inklu/keys/attributes - declarative DOM wiring
  • formatBinding(binding, platform?), bindingTokens(binding, platform?), ariaKeyShortcuts(binding, platform?), isSingleKeyBinding, seq
  • React (@inklu/keys/react):
    • useMountCommands(commands) - starts listening
    • useCommands(commands), useCommand(commands, id) - live command snapshots
    • useShortcut(commands, id) - { display, tokens, aria } for the primary binding
    • usePendingSequences(commands) - the sequences that have started, for a "G …" hint
    • useScope(commands, name, active, { exclusive }) - opens a scope while a component needs it
    • useCommandHandler(commands, id, run, enabled) - what a command does while a component is mounted
    • useRebind(commands, id, options) - records user shortcuts

Inside a handler, the context's commands is typed with the registry's own ids, so commands.run("typo") is a compile error.

Accessibility

Shortcuts are written the way each platform writes them: ⇧⌘S on a Mac, symbols run together in Apple's order, and Ctrl+Shift+S elsewhere. tokens holds the same keys one at a time, for a <kbd> each.

Every command carries an aria string for the aria-keyshortcuts attribute. It holds key names (Meta+K), which is what screen readers expect, not glyphs, and lists chords only: the attribute separates alternatives with spaces, so it cannot express a sequence. For a sequence, aria is empty; write its hint in visible text next to the control ("G then I"), which is read as words:

const { aria, display } = useShortcut(commands, "nav.inbox");
<button aria-keyshortcuts={aria || undefined}>Inbox {display}</button>;

WCAG 2.1.4 (Character Key Shortcuts) requires that users can turn off shortcuts made of printable characters without a modifier — ?, G then I — because speech input and stray keypresses trigger them. Expose a setting wired to commands.setSingleKeyShortcuts(false) (or createCommands(defs, { singleKeyShortcuts: false })). While off, those bindings neither fire nor appear in display, tokens or aria; chords with Control, Alt or Meta and keys like Escape or F1 keep working. Users can also remap any shortcut with useRebind.

Acknowledgements

The rules for matching a keypress across keyboard layouts follow the matcher in TanStack Hotkeys (MIT), which earlier releases depended on.

License

MIT © nkurunziza-saddy