Skip to main content

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:

ModifierAliases
Controlctrl, control
Shiftshift
Altalt, option
Metameta, 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: escEscape, spaceSpace, upArrowUp, enterEnter, delDelete, 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 })
ValueBehavior
"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"
OptionTypeDefaultDescription
platform"mac" | "windows" | "linux" | "auto""auto"Target platform for modifier names
style"symbols" | "text" | "mixed""mixed"Display style for keys
separatorstring" "Separator between modifier and key
sequenceSeparatorstring" then "Separator for key sequences
useShortNamesbooleantrueAbbreviate 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