Skip to content
On this page

@inklu/keys

Updated October 2026

@inklu/keys is a command system for the web. A command is something your app can do: an id, a title, a handler. A binding is a key that triggers it. Keeping them apart lets one command run from the keyboard, a palette, a menu or code, and lets users rebind it without touching the definition. Key matching comes from TanStack Hotkeys.

Installation

$
pnpm add @inklu/keys

Three entry points: @inklu/keys (framework-agnostic), @inklu/keys/react (hooks; needs React) and @inklu/keys/attributes (plain markup).

Quick start

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

import { createCommands, seq } from "@inklu/keys";
export const commands = createCommands({
"palette.open": {
title: "Open command palette",
keys: "Mod+K",
run: () => setPaletteOpen(true),
},
"doc.save": {
title: "Save document",
keys: "Mod+S",
when: () => isDirty(),
run: () => save(),
},
"nav.inbox": {
title: "Go to inbox",
keys: seq("G", "I"), // press G, then I
run: () => navigate("/inbox"),
},
});
// Start listening. The returned function stops again.
const stop = commands.mount();

Mod is ⌘ on macOS and Ctrl elsewhere.

AI setup

Paste this prompt into Claude Code, Cursor or any coding agent with write access to your project. It needs no other context.

Prompt
Set up keyboard shortcuts and a command palette using the `@inklu/keys` package.

Do not invent APIs. If something here is ambiguous, read https://inklu.saddy.me/keys
before guessing.

## 1. Install

`npm install @inklu/keys`

## 2. Define commands in one place

A command is an addressable unit of behaviour. Its keys live separately in a
keymap, so users can remap anything without the definition changing. Create a
single module that exports the registry.

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

export const commands = createCommands({
  "palette.open": {
    title: "Open command palette",
    keys: "Mod+K",
    run: () => openPalette(),
  },
  "doc.save": {
    title: "Save document",
    keys: "Mod+S",
    when: () => isDirty(),
    run: () => save(),
  },
  "nav.inbox": {
    title: "Go to inbox",
    keys: seq("G", "I"),
    run: () => navigate("/inbox"),
  },
});
```

Rules:
- Use `Mod` for the primary modifier. It resolves to Command on macOS and
  Control elsewhere. Never branch on platform yourself.
- Ids are inferred from the object keys, so `commands.run("doc.save")` is typed.
  Use dotted, namespaced ids: `group.verb`.
- Every command needs a `title` — it is what a shortcut list or rebinding row
  renders.
- `keys` accepts one binding, or an array of alternatives. A chord is a string,
  `"Mod+K"`. A sequence is `seq("G", "I")` — there is no inline `"G I"` form
  and no object form of a chord. An array means alternatives, never a sequence.
- Optional palette metadata: `description`, `group` (the heading to list it
  under), `keywords` (extra search terms) and `hidden` (keep it out of search
  results, e.g. a palette's own Close).

## 3. Mount it

In React, mount it once, near the root:

```tsx
import { useMountCommands } from "@inklu/keys/react";
import { commands } from "./commands";

useMountCommands(commands);
```

There is no provider and no context. Every hook takes the registry as its first
argument — you already have it by importing it, and passing it explicitly is what
keeps the command ids typed.

Without React, call `commands.mount()` — it returns a function that unmounts.
Nothing touches the DOM until then, so the registry is safe to create at module
scope in a server-rendered app.

## 4. Build the palette from the registry

Do not maintain a separate list of commands for the palette. Read it from the
registry, which already has titles, shortcuts and current state:

```tsx
import { matchCommands, useCommands } from "@inklu/keys/react";
import { commands } from "./commands";

const list = useCommands(commands);
// each carries: id, title, description, group, keywords, hidden, scope,
// bindings, display, tokens, aria, spoken, enabled, reachable, overridden

// Every word of the query must match; titles rank first; hidden ones are left out.
// For fuzzy search, filter `list` yourself instead.
const results = matchCommands(list, query);
```

Put `command.aria` on the element that triggers the command:
`aria-keyshortcuts={command.aria || undefined}`. It is the one of the four
formats a screen reader can actually read.

`command.tokens[0]` is an array of strings for the first binding, one per key,
already platform-correct. Render each as a `<kbd>`.

## 5. Use scopes instead of guards

When a modal, dialog or palette is open, push an exclusive scope. Every global
command is then shadowed, so you never write `if (!modalOpen)` in a handler.

```tsx
import { useScope } from "@inklu/keys/react";

useScope(commands, "dialog", isOpen, { exclusive: true });
```

Commands opt into that scope with `scope: "dialog"`. A command scoped `"*"`
stays reachable even under an exclusive scope — use it for help or escape hatches.

To take back a single key rather than the whole app, skip the barrier: when two
commands share a chord, the scoped one already outranks the global one.

```ts
createCommands({
  "panel.close":  { title: "Close panel",  keys: "Escape", run: closePanel },
  "dialog.close": { title: "Close dialog", keys: "Escape", scope: "dialog", run: closeDialog },
});
// Escape → panel.close, or dialog.close while the "dialog" scope is pushed.
```

## 6. Let users rebind, if the app has settings

```ts
commands.keymap.set("palette.open", "Mod+P");  // remap
commands.keymap.reset("palette.open");         // back to the default
```

Persist with a storage getter so SSR stays safe:

```ts
createCommands(definitions, {
  persist: { key: "myapp.keys", storage: () => localStorage },
});
```

To rebind a command the app did not define — one a package shipped — use
`bindings`, not a `keymap.set` call at startup. An unknown id is a type error,
and these land as defaults, so a stored user override still wins:

```ts
createCommands(definitions, {
  bindings: {
    "palette.open": "Mod+P",  // replace the shipped default
    "doc.publish": null,      // ship it with no key at all
  },
});
```

For a recording UI, use `useRebind(commands, id)` — it captures keys, reports conflicts
via `rebind.conflicts`, and suppresses commands while recording.

## Behaviour to rely on, not re-implement

- Bare keys and Shift/Alt combos are already ignored while the user types in an
  input, textarea or contenteditable. Ctrl/Meta shortcuts and Escape still fire.
  Do not add your own `event.target` checks.
- A command calls `preventDefault()` only when it actually runs. Disabled,
  out-of-scope, `when`-blocked or outranked commands leave the key alone.
- One keypress runs exactly one command. When several share a key the winner is
  the scoped one over the global one, then the later registration. A winner
  whose `when` currently fails steps aside for the next candidate. Do not add
  your own precedence checks inside handlers.
- `when` is an imperative guard checked just before running; it is not observed
  and will not re-render a palette. For state that should be visible in the UI,
  use `commands.setEnabled(id, boolean)` or scopes.
- There is one document listener no matter how many commands exist. Do not add
  `addEventListener("keydown", …)` alongside it.

## Checklist

- [ ] One module exports the registry; nothing else defines shortcuts
- [ ] `Mod` used for the primary modifier throughout
- [ ] `useMountCommands(commands)` called once near the root — there is no provider
- [ ] Every hook takes the registry as its first argument
- [ ] Palette reads from `useCommands`, not a hand-written list
- [ ] Command triggers carry `aria-keyshortcuts` from `command.aria`
- [ ] Dialogs and overlays push an exclusive scope
- [ ] Keys reclaimed from a global command use `scope`, not a handler check
- [ ] No manual keydown listeners and no manual input-focus checks

For an agent that keeps working in the repo, install the agent skill instead. It carries the rules, not just the setup:

$
pnpm dlx skills add nkurunziza-saddy/inklu --skill inklu-keys

Agents reading the site start at /keys/llms.txt. The complete reference, including what this page leaves out, is /keys/llms-full.txt.

React

Call useMountCommands once, near the root. There is no provider: every hook takes the registry as its first argument, which keeps command ids typed.

import { useMountCommands } from "@inklu/keys/react";
import { commands } from "./commands";
export function App({ children }: { children: React.ReactNode }) {
// Starts listening for as long as this component lives. There is no provider:
// every hook takes the registry, which is what keeps the ids typed.
useMountCommands(commands);
return <>{children}</>;
}

Command palette

The registry already lists every command with its current shortcut. Add group, keywords and hidden to definitions, and filter with matchCommands:

import { matchCommands, useCommands } from "@inklu/keys/react";
import { useState } from "react";
import { commands } from "./commands";
export function Palette() {
const [query, setQuery] = useState("");
// Every word must match; titles rank first; `hidden` commands are left out.
const results = matchCommands(useCommands(commands), query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{results.map((command) => (
<button
key={command.id}
onClick={() => commands.run(command.id)}
aria-keyshortcuts={command.aria || undefined}
>
<span>{command.title}</span>
{command.tokens[0]?.map((token, i) => (
<kbd key={i}>{token}</kbd>
))}
</button>
))}
</>
);
}

Bindings

An array of keys means alternatives. Sequences use seq and must be pressed within sequenceTimeout (1000 ms by default).

keys: "Mod+K" // a chord
keys: seq("G", "I") // a sequence — press G, then I
keys: ["Mod+K", "/"] // alternatives — either one triggers it

Scopes

Push a scope while a dialog is open instead of writing if (!modalOpen) in every handler. An exclusive scope silences everything beneath it; a command scoped "*" always fires.

import { useScope } from "@inklu/keys/react";
import { commands } from "./commands";
export function ConfirmDialog({ open }: { open: boolean }) {
// While open, only commands scoped "dialog" (and "*") respond.
// Every global shortcut is shadowed — no guards needed elsewhere.
useScope(commands, "dialog", open, { exclusive: true });
return open ? <div role="dialog">…</div> : null;
}
// The commands that dialog activates:
createCommands({
"dialog.confirm": { title: "Confirm", scope: "dialog", keys: "Enter", run: confirm },
"dialog.close": { title: "Close", scope: "dialog", keys: "Escape", run: close },
});

To take back a single key, skip exclusive: a scoped command already outranks a global one on the same key.

Rebinding

Bindings live in a keymap of defaults plus user overrides, so reset is exact. Persist overrides by giving the registry a storage:

commands.keymap.set("palette.open", "Mod+P"); // remap
commands.keymap.add("palette.open", "Control+P"); // an additional key, kept on top of the default
commands.keymap.set("palette.open", null); // unbind, keep in the palette
commands.keymap.reset("palette.open"); // back to the default
commands.keymap.export(); // → JSON-safe overrides, for your own storage
commands.keymap.import(data);
createCommands(definitions, {
// A getter, so nothing touches localStorage during SSR.
persist: { key: "myapp.keys", storage: () => localStorage },
});

useRebind records keys, reports conflicts and writes the result. Other commands are silenced while it records.

import { useRebind } from "@inklu/keys/react";
import { commands } from "./commands";
export function ShortcutRow({ id, title }: { id: string; title: string }) {
const rebind = useRebind(commands, id);
return (
<div>
<span>{title}</span>
<button onClick={rebind.recording ? rebind.cancel : rebind.start}>
{rebind.recording ? "Press keys…" : rebind.display}
</button>
{rebind.conflicts.length > 0 && (
<small>Also used by {rebind.conflicts[0].id}</small>
)}
</div>
);
}

Behaviour

  • One keypress runs one command. A scoped command beats a global one, then the later registration wins. A winner whose when fails steps aside.
  • Inputs. In text fields only Ctrl/Meta chords and Escape fire. Override with ignoreInputs.
  • Events. preventDefault() is called only when a command runs. Propagation is never stopped.
  • Debugging. commands.whyNotRun(id) returns "disabled", "out-of-scope", "guarded", "unknown" or null.
  • Server rendering. Shortcuts render as Ctrl until mount(), then switch to the real platform. Pass platform to pin it.

Accessibility

Put each command's aria string on the control that runs it. It holds key names (Meta+K), which is what screen readers expect, not glyphs.

const { display, tokens, aria } = useShortcut(commands, "doc.save");
<button onClick={() => commands.run("doc.save")} aria-keyshortcuts={aria || undefined}>
Save
{tokens.map((token, i) => (
<kbd key={i}>{token}</kbd>
))}
</button>;
// display → "⌘S" what the eye reads
// tokens → ["⌘", "S"] one <kbd> per key
// aria → "Meta+S" what a screen reader reads

Sequences have no aria-keyshortcuts form, so aria is empty for them; use spoken ("G then I") instead. Offer a setting wired to setSingleKeyShortcuts(false) so users can turn off single-key shortcuts (WCAG 2.1.4).

API

createCommands(definitions, options) // → Commands
// options: { bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }
commands.run(id) / canRun(id) / whyNotRun(id)
commands.setEnabled(id, enabled)
commands.add(id, definition) // → { run, dispose }
commands.pushScope(name, { exclusive }) // → release()
commands.conflicts(binding, { ignore, scope })
commands.display(id) / commands.tokens(id)
commands.mount() // → stop()
commands.keymap // get / set / add / reset / export / import
matchCommands(commands.list(), query)

React hooks: useMountCommands, useCommands, useCommand, useShortcut, useScope, useRegisterCommand, useRebind, usePendingSequences, useHeldKeys.

Runtime commands, package-shipped bindings, the data-command markup API, conflict kinds and createRebinder are in the README, also served for agents as /keys/llms-full.txt.