Hotkeys
A framework-agnostic utility for managing keyboard shortcuts. Supports modifier combinations, key sequences, scopes, element targeting, recording, validation, and platform-adaptive keys.
Installation
npm install @zag-js/hotkeys
Quick start
Create a store, register commands, and attach to the DOM.
import { createHotkeyStore } from "@zag-js/hotkeys" const store = createHotkeyStore() store.register([ { id: "save", hotkey: "mod+S", action: () => save(), options: { preventDefault: true }, }, { id: "undo", hotkey: "mod+Z", action: () => undo() }, ]) // Attach to the DOM (typically in a lifecycle hook) store.init({ target: document }) // Cleanup when done store.destroy()
The mod modifier resolves to Cmd on macOS and Ctrl on Windows/Linux,
so your shortcuts work across platforms without extra logic.
Usage with React
import { createHotkeyStore } from "@zag-js/hotkeys" import { useEffect } from "react" const store = createHotkeyStore() function App() { useEffect(() => { store.register([ { id: "save", hotkey: "mod+S", action: () => save(), options: { preventDefault: true }, }, { id: "undo", hotkey: "mod+Z", action: () => undo() }, ]) store.init({ target: document }) return () => store.destroy() }, []) return <div>Press Cmd+S to save</div> }
Hotkey syntax
Modifier keys
Use + to combine modifiers with a key.
store.register({ id: "copy", hotkey: "Control+C", action: handleCopy }) store.register({ id: "bold", hotkey: "Alt+Shift+B", action: handleBold })
Supported modifier names and aliases:
| Modifier | Aliases |
|---|---|
| Control | ctrl, control |
| Shift | shift |
| Alt | alt, option |
| Meta | meta, cmd, command, win |
Cross-platform modifier
Use mod or ControlOrMeta to map to Cmd on macOS and Ctrl on
Windows/Linux.
store.register({ id: "save", hotkey: "mod+S", action: handleSave })
Key sequences
Use > to define multi-step sequences (vim-style). Keys must be pressed in
order within a timeout window (default 1000ms).
store.register({ id: "go-home", hotkey: "G > H", action: () => navigateTo("/"), }) store.register({ id: "go-issues", hotkey: "G > I", action: () => navigateTo("/issues"), })
Sequences support modifiers on individual steps:
store.register({ id: "debug", hotkey: "Ctrl+K > D", action: toggleDebug })
Special keys
store.register({ id: "help", hotkey: "F1", action: showHelp }) store.register({ id: "close", hotkey: "Escape", action: closeModal }) store.register({ id: "confirm", hotkey: "Enter", action: confirm }) store.register({ id: "up", hotkey: "ArrowUp", action: moveUp })
Aliases are supported: esc → Escape, space → Space, up → ArrowUp,
enter → Enter, del → Delete, etc.
Plus key
The + character is handled by context. The last segment after modifiers is
always treated as the key.
store.register({ id: "plus", hotkey: "+", action: handlePlus }) store.register({ id: "zoom-in", hotkey: "mod++", action: zoomIn })
Scopes
Scopes activate or deactivate groups of hotkeys. A command only fires when one
of its scopes is active. The default scope is "*" (always active).
Use scopes for modal dialogs, multi-panel layouts, or any UI where different areas own different shortcuts.
const store = createHotkeyStore({ activeScopes: ["editor"], conflictBehavior: "allow", }) // Same hotkey, different scopes. Only the active scope fires store.register({ id: "editor-run", hotkey: "mod+Enter", action: () => runCode(), scopes: ["editor"], }) store.register({ id: "terminal-run", hotkey: "mod+Enter", action: () => executeCommand(), scopes: ["terminal"], }) // Switch active scope store.setScope("terminal") // Or manage scopes incrementally store.addScope("editor") store.removeScope("editor") store.toggleScope("editor") // Check active scopes store.getActiveScopes() // ["terminal"]
Targeting an element
Use the target option to scope a command to a DOM subtree. The command only
fires when the keyboard event originates inside the target.
Pass an element, or a function that returns one. The function is resolved on every event, so it works with refs that mount after registration.
store.register({ id: "grid-down", hotkey: "ArrowDown", action: moveDown, options: { target: () => gridRef.current }, })
- When the same hotkey is registered globally and with a
target, the targeted command wins inside its subtree. - The same hotkey registered on different targets is not reported as a conflict.
- Containment is checked with
event.composedPath(), so targets inside shadow DOM work. - If the function returns
null, the command is skipped.
Conditional commands
Use enabled to turn a command on or off. Accepts a boolean, or a function
evaluated on each keypress.
let isAdmin = false store.register({ id: "admin-panel", hotkey: "mod+Shift+A", action: openAdminPanel, enabled: () => isAdmin, }) // Or toggle imperatively store.enable("admin-panel") store.disable("admin-panel") store.setEnabled("admin-panel", () => isAdmin)
Smart input handling
Hotkeys with modifiers (Ctrl, Cmd, Alt) work inside form elements by default, just like browser shortcuts. Bare keys are blocked there so they don't interfere with typing.
You can override this per command:
// Explicitly disable in form elements (even with modifiers) store.register({ id: "custom", hotkey: "mod+K", action: doSomething, options: { enableOnFormTags: false }, }) // Enable only on specific form elements store.register({ id: "input-action", hotkey: "Control+I", action: doSomething, options: { enableOnFormTags: ["input"] }, }) // Enable on content-editable elements store.register({ id: "format", hotkey: "mod+B", action: toggleBold, options: { enableOnContentEditable: true }, })
Event options
preventDefault and stopPropagation
store.register({ id: "save", hotkey: "mod+S", action: handleSave, options: { preventDefault: true, // Prevent browser default (default: true) stopPropagation: false, // Stop event bubbling (default: false) }, })
Event type
Fire on keydown (default) or keyup:
store.register({ id: "push-to-talk", hotkey: "Space", action: stopTalking, options: { eventType: "keyup" }, })
Require reset
Prevent repeated firing when holding a key. The key must be released before the command fires again.
store.register({ id: "toggle", hotkey: "mod+D", action: toggleSidebar, options: { requireReset: true }, })
Capture phase
By default, hotkeys listen in the capture phase. Set capture: false to use the
bubble phase instead:
store.register({ id: "late", hotkey: "Escape", action: handleEscape, options: { capture: false }, })
Default options
Set defaults for all commands:
const store = createHotkeyStore({ defaultOptions: { preventDefault: true, capture: true, }, })
Conflict behavior
Control what happens when two commands register the same hotkey:
const store = createHotkeyStore({ conflictBehavior: "warn", // default: log a warning, allow both })
| Value | Behavior |
|---|---|
"warn" | Log a warning, allow both (default) |
"error" | Throw an error, prevent registration |
"replace" | Silently unregister the existing command |
"allow" | No checks, silently allow duplicates |
Priority
When multiple commands match the same event, the most specific one wins:
- Sequences have highest priority
- More modifiers = higher priority
- Ties broken by registration order (earlier wins)
Command metadata
Commands can include metadata for building command palettes or help screens.
store.register({ id: "save", hotkey: "mod+S", label: "Save Document", description: "Save the current document to disk", category: "file", keywords: ["save", "persist", "write"], action: handleSave, }) // Access all registered commands const commands = store.getState().commands // Map<string, HotkeyCommand>
Key state tracking
// Check if a hotkey combo is currently held store.isPressed("mod+S") // boolean // Bare modifiers work too store.isPressed("shift") // boolean // Get all currently pressed keys store.getCurrentlyPressed() // ["Meta", "S"] // Get physical key codes currently pressed store.getPressedCodes() // ["MetaLeft", "KeyS"]
Subscriptions
Use subscribe with a selector for reactive updates:
const unsubscribe = store.subscribe( (state) => state.pressedKeys.size, (count) => console.log(`${count} keys held`), ) // Cleanup unsubscribe()
Selectors should return primitives for reliable change detection.
Managing commands
// Register multiple commands at once store.register([command1, command2, command3]) // Remove a command store.unregister("save") // Clear all commands store.clear()
Standalone matching
Use isHotKey to check if a keyboard event matches a hotkey string without a
store:
import { isHotKey } from "@zag-js/hotkeys" document.addEventListener("keydown", (event) => { if (isHotKey("mod+S", event)) { event.preventDefault() handleSave() } // Match against multiple hotkeys if (isHotKey(["mod+S", "Ctrl+Shift+S"], event)) { handleSave() } })
Comparing hotkeys
Use isHotkeyEqual to check if two hotkey strings resolve to the same keys.
Aliases like mod are resolved, and sequence steps are compared step by step.
import { isHotkeyEqual } from "@zag-js/hotkeys" isHotkeyEqual("mod+S", "Meta+S") // true on Mac isHotkeyEqual("ctrl+z", "Control+Z") // true isHotkeyEqual("G > H", "G > H") // true isHotkeyEqual("Ctrl+G > H", "G > Ctrl+H") // false (different step modifiers)
Validation
Validate hotkey strings at development time:
import { validateHotkey, assertValidHotkey } from "@zag-js/hotkeys" const result = validateHotkey("mod+S") // { valid: true, errors: [], warnings: [] } const result2 = validateHotkey("Foo+S") // { valid: false, errors: ['"Foo" is not a recognized modifier'], warnings: [] } // Throws on invalid input, useful during development assertValidHotkey("mod+S") // ok assertValidHotkey("Foo+S") // throws Error
Formatting for display
Use formatHotkey to render hotkey strings with platform-specific symbols:
import { formatHotkey } from "@zag-js/hotkeys" formatHotkey("mod+S", { platform: "mac" }) // "⌘ S" formatHotkey("mod+S", { platform: "windows" }) // "Ctrl S" formatHotkey("meta+S", { platform: "linux" }) // "Super S" formatHotkey("Alt+Shift+F", { platform: "mac", style: "symbols" }) // "⌥ ⇧ F" formatHotkey("G > H", { sequenceSeparator: " then " }) // "G then H"
| Option | Type | Default | Description |
|---|---|---|---|
platform | "mac" | "windows" | "linux" | "auto" | "auto" | Target platform for modifier names |
style | "symbols" | "text" | "mixed" | "mixed" | Display style for keys |
separator | string | " " | Separator between modifier and key |
sequenceSeparator | string | " then " | Separator for key sequences |
useShortNames | boolean | true | Abbreviate modifier names |
Recording hotkeys
Use createHotkeyRecorder to let users record custom key combinations. It
handles single chords and multi-step sequences.
import { createHotkeyRecorder } from "@zag-js/hotkeys" const recorder = createHotkeyRecorder({ target: document }) recorder.setOptions({ onRecord(hotkey) { console.log("Recorded:", hotkey.value, hotkey.display) }, onCancel() { console.log("Cancelled") }, }) // Start listening for key input recorder.start() // User presses Shift+K → records "Shift+K" // User presses G then H → records "G > H" (auto-detected sequence) // Cancel or clear recorder.cancel() // stops, restores the value from before recording, invokes onCancel recorder.clear() // stops, clears value, invokes onClear // Stop and finalize (accepts whatever has been captured so far; // keeps the previous value if nothing was captured) recorder.stop() // Read state recorder.getState() // { recording: boolean, value: RecordedHotkey | null } // Subscribe to state changes const unsub = recorder.subscribe((state) => { console.log(state.recording, state.value?.display) }) // Cleanup recorder.destroy()
Special keys during recording
- Escape cancels recording and invokes
onCancel - Backspace / Delete clears the recorded value and invokes
onClear - Modifier-only presses (Shift, Ctrl, etc.) are ignored until a non-modifier key is pressed
- Dead keys are skipped
Sequence detection
After capturing a chord, the recorder waits (default 1000ms) for another key. A key press extends the sequence; the timeout finalizes the recording.
const recorder = createHotkeyRecorder({ target: document, sequenceTimeoutMs: 800, // customize timeout })
Updating options
Use setOptions to update callbacks without recreating the recorder. This
avoids stale closure issues in React:
// In a React component, call on every render recorder.setOptions({ onRecord(hotkey) { // Always has access to current state, no refs needed saveBinding(editingId, hotkey.value) }, })
Lifecycle
// Create store const store = createHotkeyStore() // Register commands (can be done before or after init) store.register([...]) // Attach to the DOM and start listening for keyboard events store.init({ target: document }) // Detach and clean up store.destroy()
The store can also be initialized inline:
const store = createHotkeyStore({ target: document })
The target accepts Document, ShadowRoot, or any Element, so you can
scope hotkeys to a specific panel or widget.
Caveats
Symbol keys on non-US keyboard layouts
Cmd/Ctrl + symbol key hotkeys (like mod+/, mod+[, mod+]) can fail on
non-US keyboard layouts. On a German keyboard, / requires pressing Shift+7,
and some browsers report event.key incorrectly when Cmd/Ctrl is also held.
This browser limitation affects every JavaScript shortcut library. To work around it:
- Prefer letter or digit keys for Cmd/Ctrl combinations (
mod+K,mod+S). These work reliably on every layout. - Use bare symbol keys without Cmd/Ctrl for navigation shortcuts (
/for search,?for help), like GitHub and Gmail do. - Register multiple commands if you must use Cmd/Ctrl + symbol:
store.register([ { id: "search", hotkey: "mod+/", action: openSearch }, { id: "search-alt", hotkey: "/", action: openSearch }, ])
Dead keys on international layouts
Accent/compose keys on international layouts produce event.key = "Dead" and
are automatically ignored. This prevents false matches during text composition.
macOS Option key in Firefox
On macOS, pressing Option (Alt) + a key produces composed characters (e.g., Option+/ → ÷). The library handles this by falling back to physical key code matching when a modifier has transformed the character output.
Edit this page on GitHub