Skip to main content

A framework-agnostic utility for managing keyboard shortcuts with zero dependencies. Supports modifier combinations, key sequences, scopes, 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 let you activate or deactivate groups of hotkeys. Commands only fire when at least one of their scopes is active. The default scope is "*" (always active).

This is useful for modal dialogs, multi-panel layouts, or any UI where different areas have 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"]

Conditional commands

Use enabled to conditionally enable a command. Accepts a boolean or a function that's 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

By default, hotkeys with modifiers (Ctrl, Cmd, Alt) work inside form elements — just like browser shortcuts. Bare keys without modifiers are blocked in form elements to prevent interference 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 // 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 are semantically equal. This resolves aliases like modMeta/Control and compares sequence steps.

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 — useful in dev 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("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. Automatically handles both 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, invokes onCancel recorder.clear() // stops, clears value, invokes onClear // Stop and finalize (accepts whatever has been captured so far) 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 — waits for a non-modifier key
  • Dead keys are skipped

Sequence detection

After capturing a chord, the recorder waits for the next key within a timeout window (default 1000ms). If another key is pressed, it's appended as a sequence step. If the timeout expires, the recording finalizes.

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 DOM — starts 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 — useful for scoping hotkeys to a specific panel or widget.

Caveats

Symbol keys on non-US keyboard layouts

When using Cmd/Ctrl + symbol key hotkeys (like mod+/, mod+[, mod+]), users on non-US keyboard layouts may experience issues. For example, on a German keyboard, / requires pressing Shift+7 — but some browsers report event.key incorrectly when Cmd/Ctrl is also held.

This is a known browser limitation that affects all JavaScript keyboard shortcut libraries. 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) — just 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