@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.
Maintainers
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/keys1. 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:
- A sequence being finished, over a chord on the same key.
- The command whose scope was opened most recently. Scopes track the surfaces on screen, so the dialog on top claims
Escapebefore 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". WithuseScope, "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. - 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 defaultset 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 unbindsWhile 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, likeGagainstseq("G", "I"). These cannot be arbitrated: pressingGruns the chord, and continuing withIruns 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 registryoptions:{ bindings, platform, persist, singleKeyShortcuts, sequenceTimeout, onRun };platformis"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 withdisplay,tokens,aria,reachable,overriddencommands.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 snapshotcommands.keymap:get,set,resetcommands.setSingleKeyShortcuts(enabled),commands.singleKeyShortcuts
webStorage(key, storage?)- thepersistoption: Web StorageexportKeymap(commands),importKeymap(commands, data)- user overrides as JSON-safe datacreateRebinder(commands, id, options)- framework-agnostic shortcut recordingobserve(commands, { root })from@inklu/keys/attributes- declarative DOM wiringformatBinding(binding, platform?),bindingTokens(binding, platform?),ariaKeyShortcuts(binding, platform?),isSingleKeyBinding,seq- React (
@inklu/keys/react):useMountCommands(commands)- starts listeninguseCommands(commands),useCommand(commands, id)- live command snapshotsuseShortcut(commands, id)-{ display, tokens, aria }for the primary bindingusePendingSequences(commands)- the sequences that have started, for a "G …" hintuseScope(commands, name, active, { exclusive })- opens a scope while a component needs ituseCommandHandler(commands, id, run, enabled)- what a command does while a component is mounteduseRebind(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
