@pyreon/hotkeys
@pyreon/hotkeysstableKeyboard shortcut management for Pyreon — register global or scoped shortcuts, get automatic cleanup on unmount, and write platform-aware combos with the mod alias (⌘ on macOS, Ctrl everywhere else). Built on a single shared keydown listener so a hundred registered shortcuts cost one event handler, not a hundred.
Why a hotkey package?
Wiring keydown handlers by hand is deceptively fiddly. You end up re-solving the same problems in every app:
Lifecycle. A listener added in a component must be removed when that component unmounts — forget it and a stale handler keeps firing against a torn-down view.
Cross-platform modifiers.
Cmd+Son a Mac isCtrl+Son Windows/Linux. Hard-coding either one breaks half your users.Context. A modal's
Escapeshould close the modal, not whatever the editor underneath wantedEscapefor. You need shortcuts that only fire in the right context.Typing-vs-commanding. A shortcut like
e(edit) must not fire while the user is typingeinto a text field.Help screens. Power users expect a "keyboard shortcuts" panel. That means the set of registered shortcuts has to be introspectable.
@pyreon/hotkeys handles all of it. useHotkey() registers a shortcut and tears it down on unmount automatically. mod collapses the platform split. Scopes give you context-aware activation. Input filtering is on by default. And getRegisteredHotkeys()hands you the live registry for building help dialogs.
Everything is built on a single module-level registry and one shared keydownlistener. The listener attaches lazily on the first registration and detaches when the last shortcut unregisters — so a page with no hotkeys pays nothing, and the cost scales with one listener regardless of how many shortcuts you register.
Installation
npm install @pyreon/hotkeysbun add @pyreon/hotkeyspnpm add @pyreon/hotkeysyarn add @pyreon/hotkeysPeer dependencies: @pyreon/core, @pyreon/reactivity.
Quick Start
import { useHotkey } from '@pyreon/hotkeys'
function Editor() {
useHotkey('mod+s', (e) => {
e.preventDefault() // stop the browser's Save dialog
save()
}, { description: 'Save document' })
useHotkey('mod+z', () => undo(), { description: 'Undo' })
useHotkey('mod+shift+z', () => redo(), { description: 'Redo' })
return <textarea />
// All three shortcuts auto-unregister when Editor unmounts.
}mod resolves to ⌘ on macOS and Ctrl on Windows/Linux, so you write one shortcut and it works everywhere.
Core concepts
| Concept | What it does |
|---|---|
useHotkey() | Register a shortcut bound to the component's lifecycle — auto-unregisters on unmount. |
useHotkeyScope() | Mark a scope active for the component's lifetime. |
registerHotkey() | Imperative registration for non-component code; returns a manual unregister fn. |
| Scopes | A string label. A scoped shortcut only fires while its scope is active. 'global' is always active. |
mod alias | ⌘ on macOS, Ctrl elsewhere. |
| Input filtering | Shortcuts are suppressed while a form element / contentEditable is focused (opt out per-shortcut). |
Shortcut syntax
A shortcut string is a sequence of +-joined tokens. Tokens are case-insensitiveand surrounding whitespace within each +-segment is trimmed.
modifier+modifier+...+keyThe last non-modifier token is the key; everything else is a modifier.
useHotkey('a', ...) // the "a" key, no modifiers
useHotkey('mod+s', ...) // ⌘S / Ctrl+S
useHotkey('ctrl+shift+p', ...) // Ctrl+Shift+P
useHotkey('alt+enter', ...) // Alt+Enter
useHotkey('escape', ...) // the Escape keyModifiers
| Token | Maps to |
|---|---|
ctrl, control | Control key |
shift | Shift key |
alt | Alt / Option key |
meta, cmd, command | Meta / ⌘ key |
mod | ⌘ on macOS, Ctrl on Windows/Linux |
Prefer mod over ctrl/cmd for any cross-platform shortcut — it's the whole reason the alias exists.
Key aliases
For keys whose KeyboardEvent.key name is awkward to type, these aliases are accepted. You can always use the underlying name directly instead.
| Alias | Resolves to |
|---|---|
esc | escape |
return | enter |
del | delete |
ins | insert |
space, spacebar | " " (space) |
up | arrowup |
down | arrowdown |
left | arrowleft |
right | arrowright |
plus | + |
useHotkey('esc', () => close()) // same as 'escape'
useHotkey('mod+up', () => moveUp()) // ⌘ + ArrowUp
useHotkey('shift+space', () => pageUp())Exact-match semantics
A shortcut fires only when the held modifiers match exactly. mod+s matches ⌘S, butnot ⌘⇧S — that extra Shift makes it a different combo. This means combos can't accidentally collide by being a "subset" of a larger one: register mod+s and mod+shift+sand each fires only for its precise modifier set.
useHotkey('mod+s', () => save()) // fires on ⌘S only
useHotkey('mod+shift+s', () => saveAs()) // fires on ⌘⇧S onlyThe one exception is single symbol keys. ?, !, +, and friends are typed withShift on a standard layout, so exact-match would never let them fire. Instead the Shift modifier is ignored for single-symbol bindings — the produced event.key already encodes the character. Bind the symbol directly:
useHotkey('?', () => openHelp()) // fires on the real Shift+/ keystrokeDon't write shift+? — the symbol already implies Shift, and / vs ? stay distinct because their event.key values differ. Letters and named keys (a, arrowup) keep exact Shift-matching, so a never matches Shift+A.
Sequential combos
Separate combos with a space to require them to be pressed in order. Each step must arrive within 1 second of the previous one, or the sequence resets.
// Press "g", then "t" (Gmail-style navigation)
useHotkey('g t', () => goToTasks())
useHotkey('g i', () => goToInbox())
// Steps can carry their own modifiers
useHotkey('ctrl+k p', () => openPalette()) // Ctrl+K, then PEach step is parsed with the same syntax as a single shortcut, so modifiers and aliases work at every position. While a sequence is mid-flight, the matched first keystroke is consumed (its default is prevented if preventDefault is on) but the handler does not fire until the full sequence completes.
Component hook — useHotkey()
useHotkey(shortcut, handler, options?) registers a shortcut and ties it to the calling component. When the component unmounts, the shortcut is automatically unregistered — you never write cleanup code.
import { useHotkey } from '@pyreon/hotkeys'
function App() {
useHotkey('mod+k', () => openCommandPalette())
useHotkey('escape', () => closeModal())
useHotkey('ctrl+shift+p', () => openSettings(), {
description: 'Open settings',
})
}The handler receives the raw KeyboardEvent, so you can inspect e.key, calle.preventDefault(), etc.
Options
useHotkey and registerHotkey both accept a HotkeyOptions object:
| Option | Type | Default | Description |
|---|---|---|---|
scope | string | 'global' | Shortcut only fires while this scope is active. |
preventDefault | boolean | true | Call e.preventDefault() before the handler runs. |
stopPropagation | boolean | false | Call e.stopPropagation() before the handler runs. |
enableOnInputs | boolean | false | Fire even when an input / textarea / select / contentEditable is focused. |
description | string | — | Human-readable label, surfaced by getRegisteredHotkeys() for help dialogs. |
enabled | boolean | (() => boolean) | true | Whether the shortcut is active. A function is re-evaluated on every keystroke. |
Dynamic enable / disable
enabled accepts a function, re-evaluated on each matching keystroke — wire it to a signal or computed for reactive gating without re-registering.
import { computed } from '@pyreon/reactivity'
const canSave = computed(() => hasChanges() && !isSaving())
useHotkey('mod+s', () => save(), {
enabled: () => canSave(),
description: 'Save (only when there are unsaved changes)',
})When enabled returns false, the shortcut is skipped during dispatch as if it weren't registered — no cleanup, no re-register.
Input filtering
By default, shortcuts are suppressed while the user is focused on a text-entry surface, so a single-key shortcut like e doesn't fire while someone types "e" into a field. The suppressed surfaces are:
<input>elements<textarea>elements<select>elementsany element with
contentEditable
Opt out per-shortcut with enableOnInputs: true, or selectively with an array of surface kinds — ['input'] fires in text inputs but stays suppressed in textareas/selects/contenteditables. escape is the classic candidate for true — you usually want it to dismiss a dialog even while a field inside it has focus.
// Fires even when an input is focused
useHotkey('escape', () => closeModal(), { enableOnInputs: true })
// Fires in <input> fields only (autocomplete accept), not in textareas
useHotkey('tab', acceptSuggestion, { enableOnInputs: ['input'] })
// Stays suppressed while typing (the default)
useHotkey('e', () => startEditing())Comma lists, keyup, once, repeat
Comma-separated lists bind several shortcuts to one handler — one call, one unregister for all of them. A literal comma key is spelled comma (the raw 'ctrl+,'also parses).
useHotkey('ctrl+s, mod+p', save) // either combo saves
useHotkey('g d, g h', goHome) // two sequences, one handler
useHotkey('mod+comma', openSettings) // the literal comma keyevent: 'keyup' fires on release — push-to-talk-style interactions. Sequences ('g t') are keydown-only and reject 'keyup' at registration.
useHotkey('space', startTalk)
useHotkey('space', stopTalk, { event: 'keyup' })once: true fires at most once, then auto-unregisters — onboarding nudges, "press any key to continue" moments.
ignoreRepeat: true skips the auto-repeat events a held key produces (event.repeat) — turn it on for one-shot actions so holding mod+s doesn't machine-gun the save handler. Off by default (games and scrubbing UIs want repeats).
Element-scoped shortcuts — target
By default shortcuts listen on window. Pass target to bind to a specific element — the shortcut fires only while the keyboard event reaches that element (focus is inside it). The registry keeps ONE shared listener per (target, event-type) and detaches it when the target's last hotkey unregisters.
function SearchPanel() {
let panel: HTMLElement | null = null
onMount(() => {
const un = registerHotkey('enter', submit, { target: panel! })
return un
})
return <div ref={(el) => (panel = el as HTMLElement)}>…</div>
}Pressed keys & programmatic trigger
getPressedKeys() returns a reactive Signal<Set<string>> of the currently-held keys (lower-cased event.key values). Tracking listeners attach lazily on first call — zero overhead until you use it — and the set clears on window blur (a key released outside the page never delivers its keyup). isKeyPressed('shift') is the non-reactive point read (aliases resolve).
const pressed = getPressedKeys()
effect(() => cursor.set(pressed().has('shift') ? 'copy' : 'move'))trigger(shortcut) programmatically fires the handlers bound to a shortcut — the "run the bound action" primitive for command palettes, macros, and tests. It respects the scope and enabled gates (pass { scope } to target a specific, even inactive, scope) and returns the number of handlers fired.
trigger('mod+s') // as if the user pressed it
trigger('g t') // sequences by their full spelling
trigger('ctrl+z', { scope: 'editor' }) // explicit scopeScopes — useHotkeyScope()
Scopes turn shortcuts on and off based on UI context. A shortcut registered with ascope option only fires while that scope is active; useHotkeyScope(scope) marks a scope active for the calling component's lifetime.
import { useHotkey, useHotkeyScope } from '@pyreon/hotkeys'
function Modal() {
useHotkeyScope('modal') // 'modal' is active while Modal is mounted
useHotkey('escape', () => close(), { scope: 'modal' })
useHotkey('enter', () => confirm(), { scope: 'modal' })
}
function Editor() {
useHotkeyScope('editor')
useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
useHotkey('ctrl+shift+z', () => redo(), { scope: 'editor' })
}The 'global' scope is always active and is the default for any shortcut that doesn't set a scope. Custom scopes are inactive until something activates them.
Detecting scope conflicts
If you register the same combo in two scopes that are active at the same time, bothhandlers fire, in registration order. That cross-scope overlap is intentional layering. A genuine bug is the same combo registered twice in the same scope — getHotkeyConflicts()surfaces exactly those (see Introspection).
// Intentional layering — both fire if 'editor' and 'modal' are active:
useHotkey('escape', () => clearSelection(), { scope: 'editor' })
useHotkey('escape', () => closeModal(), { scope: 'modal' })
// A same-scope duplicate — getHotkeyConflicts() flags this pair:
useHotkey('mod+s', saveDraft, { scope: 'editor' })
useHotkey('mod+s', saveAll, { scope: 'editor' })Imperative API — registerHotkey()
For code that runs outside a component — stores, middleware, plugins, one-time app setup — use registerHotkey(). It takes the same arguments as useHotkey but returns anunregister function instead of auto-cleaning-up.
import { registerHotkey } from '@pyreon/hotkeys'
const unregister = registerHotkey('ctrl+q', () => quit(), {
scope: 'global',
description: 'Quit',
})
// You own the cleanup:
unregister()Imperative scope control
Outside a component, manage scopes with enableScope / disableScope:
import { enableScope, disableScope, getActiveScopes } from '@pyreon/hotkeys'
enableScope('editor') // turn a scope on
disableScope('editor') // turn it off — 'global' can't be disabled
// Inspect the active set reactively
const scopes = getActiveScopes() // Signal<Set<string>>
console.log([...scopes()]) // e.g. ['global', 'editor']getActiveScopes() returns the underlying reactive Signal<Set<string>>, so you can read it inside an effect or computed to react to scope changes. disableScope('global') is a no-op — the global scope can never be turned off.
Introspection & help dialogs
getRegisteredHotkeys() returns a read-only snapshot of every registered shortcut. Each entry has { shortcut, scope, description? } — enough to render a "keyboard shortcuts" panel.
import { getRegisteredHotkeys, formatCombo, parseShortcut } from '@pyreon/hotkeys'
function ShortcutHelp() {
const hotkeys = getRegisteredHotkeys()
return (
<table>
<thead>
<tr>
<th>Shortcut</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{hotkeys
.filter((h) => h.description)
.map((h) => (
<tr>
<td>
<kbd>{formatCombo(parseShortcut(h.shortcut))}</kbd>
</td>
<td>{h.description}</td>
</tr>
))}
</tbody>
</table>
)
}Conflict detection
getHotkeyConflicts() flags shortcuts that would fire on the same keystroke within the same scope — the classic "two handlers, one binding, both fire" bug. Matching is on theparsed combo, not the source string, so aliased duplicates (ctrl+s vs control+s, ormod+s vs ctrl+s off Mac) are caught too. Cross-scope overlaps are intentional layering and are not reported.
import { getHotkeyConflicts } from '@pyreon/hotkeys'
// A dev-only audit panel — or assert `getHotkeyConflicts()` is empty in a test.
function ConflictWarnings() {
const conflicts = getHotkeyConflicts()
if (conflicts.length === 0) return null
return (
<ul>
{conflicts.map((c) => (
<li>
Conflict in <code>{c.scope}</code>: {c.shortcuts.join(', ')}
</li>
))}
</ul>
)
}Each conflict is { scope, shortcuts, descriptions } — the colliding source strings and their descriptions (parallel arrays, undefined where none was set).
Utilities
Three pure functions back the matching and formatting machinery. They're exported so you can use them directly — e.g. for custom rendering or testing.
import { parseShortcut, formatCombo, matchesCombo } from '@pyreon/hotkeys'
// Parse a single combo string into a KeyCombo
const combo = parseShortcut('ctrl+shift+s')
// → { ctrl: true, shift: true, alt: false, meta: false, key: 's' }
// Format a KeyCombo back to a human-readable label.
// Meta renders as ⌘ on Mac, "Meta" elsewhere.
formatCombo(combo) // 'Ctrl+Shift+S'
// Check whether a KeyboardEvent matches a combo (exact modifiers)
window.addEventListener('keydown', (e) => {
if (matchesCombo(e, combo)) {
/* matched */
}
})parseShortcut understands a single combo (modifiers joined with +) — it does not split on spaces, so pass it one combo at a time, not a sequential shortcut like 'g t'.
Server-side rendering
@pyreon/hotkeys drives a browser keydown listener, so on the server every mutating entry point is a no-op: registerHotkey records nothing and returns an inert unregister, and enableScope / disableScope don't touch scope state. This matters because the registry is a module-level singleton shared across every SSR request — recording entries or flipping scopes on the server would leak unboundedly (no unmount fires during renderToString) and bleed one request's hotkeys into the next. You don't need to wrap useHotkey in a browser guard; shortcuts come alive on the client when the registry attaches its listener on the first registration during hydration.
Because registration is a client-runtime concern, getRegisteredHotkeys() returns an empty list on the server. Build a server-rendered "keyboard shortcuts" panel from a static config rather than the live registry.
Common mistakes
API Reference
Hooks
| Export | Signature | Description |
|---|---|---|
useHotkey | (shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => void | Register a shortcut bound to the component's lifecycle. Auto-unregisters on unmount. |
useHotkeyScope | (scope: string) => void | Activate a scope for the component's lifetime; deactivates on unmount. Reference-counted, so stacked components sharing a scope stay correct. |
Imperative API
| Export | Signature | Description |
|---|---|---|
registerHotkey | (shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => () => void | Register a shortcut imperatively. Returns an unregister function; no auto-cleanup. No-op on the server. |
enableScope | (scope: string) => void | Acquire a scope (reference-counted). No-op on the server. |
disableScope | (scope: string) => void | Release a scope (reference-counted). No-op for 'global' and on the server. |
getActiveScopes | () => Signal<Set<string>> | The reactive set of currently-active scopes. |
getRegisteredHotkeys | () => ReadonlyArray<{ shortcut: string; scope: string; description?: string }> | Snapshot of every registered shortcut, for help dialogs. Empty on the server. |
getHotkeyConflicts | () => ReadonlyArray<{ scope: string; shortcuts: string[]; descriptions: Array<string | undefined> }> | Registered shortcuts that collide (same parsed combo, same scope). |
Utilities
| Export | Signature | Description |
|---|---|---|
parseShortcut | (shortcut: string) => KeyCombo | Parse a single combo string into a KeyCombo. Supports aliases and mod. |
matchesCombo | (event: KeyboardEvent, combo: KeyCombo) => boolean | Whether the event's modifiers + key match the combo (Shift is ignored for single-symbol keys like ?). |
formatCombo | (combo: KeyCombo) => string | Human-readable label (⌘ for Meta on Mac, Meta elsewhere). |
Options — HotkeyOptions
| Field | Type | Default | Description |
|---|---|---|---|
scope | string | 'global' | Scope the shortcut belongs to. |
preventDefault | boolean | true | Call e.preventDefault() before the handler. |
stopPropagation | boolean | false | Call e.stopPropagation() before the handler. |
enableOnInputs | boolean | false | Fire even when a form element / contentEditable is focused. |
description | string | — | Human-readable label for help dialogs. |
enabled | boolean | (() => boolean) | true | Whether the shortcut is active; a function is re-evaluated per keystroke. |
Types
| Type | Shape | Description |
|---|---|---|
KeyCombo | { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean; key: string } | A parsed single key combination. |
HotkeyOptions | see table above | Registration options for useHotkey / registerHotkey. |
HotkeyEntry | { shortcut: string; combo: KeyCombo; sequence: KeyCombo[]; handler; options } | A fully-resolved registry entry (internal shape). For sequential combos, combo is the first step and sequence holds the rest. |