# @inklu/keys > A command system for the web: typed commands, remappable keybindings, scopes, sequences, conflict detection and platform-aware display. - Install: `npm install @inklu/keys` - Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-keys` ## Docs - [Full reference](https://inklu.saddy.me/keys/llms-full.txt): every export, option and pattern, as Markdown - [Documentation](https://inklu.saddy.me/keys): Commands, React hooks, bindings, scopes, rebinding and the API - [Try it out](https://inklu.saddy.me/keys/try-it-out): A command palette and live key monitor ## Optional - [Source](https://github.com/nkurunziza-saddy/inklu/tree/main/packages/keys) - [npm](https://www.npmjs.com/package/@inklu/keys) ## Setup 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 ``. ### 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