{"slug":"hotkeys","title":"Hotkeys","description":"Using the hotkeys utility in your project.","contentType":"component","content":"A framework-agnostic utility for managing keyboard shortcuts with zero\ndependencies. Supports modifier combinations, key sequences, scopes, recording,\nvalidation, and platform-adaptive keys.\n\n## Resources\n\n\n[Latest version: v](https://www.npmjs.com/package/@zag-js/hotkeys)\n[Logic Visualizer](https://zag-visualizer.vercel.app/hotkeys)\n[Source Code](https://github.com/chakra-ui/zag/tree/main/packages/machines/hotkeys)\n\n## Installation\n\n```bash\nnpm install @zag-js/hotkeys\n```\n\n## Quick start\n\nCreate a store, register commands, and attach to the DOM.\n\n```tsx\nimport { createHotkeyStore } from \"@zag-js/hotkeys\"\n\nconst store = createHotkeyStore()\n\nstore.register([\n  {\n    id: \"save\",\n    hotkey: \"mod+S\",\n    action: () => save(),\n    options: { preventDefault: true },\n  },\n  { id: \"undo\", hotkey: \"mod+Z\", action: () => undo() },\n])\n\n// Attach to the DOM (typically in a lifecycle hook)\nstore.init({ target: document })\n\n// Cleanup when done\nstore.destroy()\n```\n\nThe `mod` modifier resolves to **Cmd on macOS** and **Ctrl on Windows/Linux**,\nso your shortcuts work across platforms without extra logic.\n\n### Usage with React\n\n```tsx\nimport { createHotkeyStore } from \"@zag-js/hotkeys\"\nimport { useEffect } from \"react\"\n\nconst store = createHotkeyStore()\n\nfunction App() {\n  useEffect(() => {\n    store.register([\n      {\n        id: \"save\",\n        hotkey: \"mod+S\",\n        action: () => save(),\n        options: { preventDefault: true },\n      },\n      { id: \"undo\", hotkey: \"mod+Z\", action: () => undo() },\n    ])\n    store.init({ target: document })\n    return () => store.destroy()\n  }, [])\n\n  return <div>Press Cmd+S to save</div>\n}\n```\n\n## Hotkey syntax\n\n### Modifier keys\n\nUse `+` to combine modifiers with a key.\n\n```tsx\nstore.register({ id: \"copy\", hotkey: \"Control+C\", action: handleCopy })\nstore.register({ id: \"bold\", hotkey: \"Alt+Shift+B\", action: handleBold })\n```\n\nSupported modifier names and aliases:\n\n| Modifier | Aliases                         |\n| -------- | ------------------------------- |\n| Control  | `ctrl`, `control`               |\n| Shift    | `shift`                         |\n| Alt      | `alt`, `option`                 |\n| Meta     | `meta`, `cmd`, `command`, `win` |\n\n### Cross-platform modifier\n\nUse `mod` or `ControlOrMeta` to map to **Cmd on macOS** and **Ctrl on\nWindows/Linux**.\n\n```tsx\nstore.register({ id: \"save\", hotkey: \"mod+S\", action: handleSave })\n```\n\n### Key sequences\n\nUse `>` to define multi-step sequences (vim-style). Keys must be pressed in\norder within a timeout window (default 1000ms).\n\n```tsx\nstore.register({\n  id: \"go-home\",\n  hotkey: \"G > H\",\n  action: () => navigateTo(\"/\"),\n})\nstore.register({\n  id: \"go-issues\",\n  hotkey: \"G > I\",\n  action: () => navigateTo(\"/issues\"),\n})\n```\n\nSequences support modifiers on individual steps:\n\n```tsx\nstore.register({ id: \"debug\", hotkey: \"Ctrl+K > D\", action: toggleDebug })\n```\n\n### Special keys\n\n```tsx\nstore.register({ id: \"help\", hotkey: \"F1\", action: showHelp })\nstore.register({ id: \"close\", hotkey: \"Escape\", action: closeModal })\nstore.register({ id: \"confirm\", hotkey: \"Enter\", action: confirm })\nstore.register({ id: \"up\", hotkey: \"ArrowUp\", action: moveUp })\n```\n\nAliases are supported: `esc` → `Escape`, `space` → `Space`, `up` → `ArrowUp`,\n`enter` → `Enter`, `del` → `Delete`, etc.\n\n### Plus key\n\nThe `+` character is handled by context. The last segment after modifiers is\nalways treated as the key.\n\n```tsx\nstore.register({ id: \"plus\", hotkey: \"+\", action: handlePlus })\nstore.register({ id: \"zoom-in\", hotkey: \"mod++\", action: zoomIn })\n```\n\n## Scopes\n\nScopes let you activate or deactivate groups of hotkeys. Commands only fire when\nat least one of their scopes is active. The default scope is `\"*\"` (always\nactive).\n\nThis is useful for modal dialogs, multi-panel layouts, or any UI where different\nareas have different shortcuts.\n\n```tsx\nconst store = createHotkeyStore({\n  activeScopes: [\"editor\"],\n  conflictBehavior: \"allow\",\n})\n\n// Same hotkey, different scopes — only the active scope fires\nstore.register({\n  id: \"editor-run\",\n  hotkey: \"mod+Enter\",\n  action: () => runCode(),\n  scopes: [\"editor\"],\n})\n\nstore.register({\n  id: \"terminal-run\",\n  hotkey: \"mod+Enter\",\n  action: () => executeCommand(),\n  scopes: [\"terminal\"],\n})\n\n// Switch active scope\nstore.setScope(\"terminal\")\n\n// Or manage scopes incrementally\nstore.addScope(\"editor\")\nstore.removeScope(\"editor\")\nstore.toggleScope(\"editor\")\n\n// Check active scopes\nstore.getActiveScopes() // [\"terminal\"]\n```\n\n## Conditional commands\n\nUse `enabled` to conditionally enable a command. Accepts a boolean or a function\nthat's evaluated on each keypress.\n\n```tsx\nlet isAdmin = false\n\nstore.register({\n  id: \"admin-panel\",\n  hotkey: \"mod+Shift+A\",\n  action: openAdminPanel,\n  enabled: () => isAdmin,\n})\n\n// Or toggle imperatively\nstore.enable(\"admin-panel\")\nstore.disable(\"admin-panel\")\nstore.setEnabled(\"admin-panel\", () => isAdmin)\n```\n\n## Smart input handling\n\nBy default, hotkeys with modifiers (Ctrl, Cmd, Alt) **work inside form\nelements** — just like browser shortcuts. Bare keys without modifiers are\n**blocked** in form elements to prevent interference with typing.\n\nYou can override this per command:\n\n```tsx\n// Explicitly disable in form elements (even with modifiers)\nstore.register({\n  id: \"custom\",\n  hotkey: \"mod+K\",\n  action: doSomething,\n  options: { enableOnFormTags: false },\n})\n\n// Enable only on specific form elements\nstore.register({\n  id: \"input-action\",\n  hotkey: \"Control+I\",\n  action: doSomething,\n  options: { enableOnFormTags: [\"input\"] },\n})\n\n// Enable on content-editable elements\nstore.register({\n  id: \"format\",\n  hotkey: \"mod+B\",\n  action: toggleBold,\n  options: { enableOnContentEditable: true },\n})\n```\n\n## Event options\n\n### preventDefault and stopPropagation\n\n```tsx\nstore.register({\n  id: \"save\",\n  hotkey: \"mod+S\",\n  action: handleSave,\n  options: {\n    preventDefault: true, // Prevent browser default (default: true)\n    stopPropagation: false, // Stop event bubbling (default: false)\n  },\n})\n```\n\n### Event type\n\nFire on keydown (default) or keyup:\n\n```tsx\nstore.register({\n  id: \"push-to-talk\",\n  hotkey: \"Space\",\n  action: stopTalking,\n  options: { eventType: \"keyup\" },\n})\n```\n\n### Require reset\n\nPrevent repeated firing when holding a key. The key must be released before the\ncommand fires again.\n\n```tsx\nstore.register({\n  id: \"toggle\",\n  hotkey: \"mod+D\",\n  action: toggleSidebar,\n  options: { requireReset: true },\n})\n```\n\n### Capture phase\n\nBy default, hotkeys listen in the capture phase. Set `capture: false` to use the\nbubble phase instead:\n\n```tsx\nstore.register({\n  id: \"late\",\n  hotkey: \"Escape\",\n  action: handleEscape,\n  options: { capture: false },\n})\n```\n\n### Default options\n\nSet defaults for all commands:\n\n```tsx\nconst store = createHotkeyStore({\n  defaultOptions: {\n    preventDefault: true,\n    capture: true,\n  },\n})\n```\n\n## Conflict behavior\n\nControl what happens when two commands register the same hotkey:\n\n```tsx\nconst store = createHotkeyStore({\n  conflictBehavior: \"warn\", // default — log a warning, allow both\n})\n```\n\n| Value       | Behavior                                 |\n| ----------- | ---------------------------------------- |\n| `\"warn\"`    | Log a warning, allow both (default)      |\n| `\"error\"`   | Throw an error, prevent registration     |\n| `\"replace\"` | Silently unregister the existing command |\n| `\"allow\"`   | No checks, silently allow duplicates     |\n\n## Priority\n\nWhen multiple commands match the same event, the most specific one wins:\n\n- Sequences have highest priority\n- More modifiers = higher priority\n- Ties broken by registration order (earlier wins)\n\n## Command metadata\n\nCommands can include metadata for building command palettes or help screens.\n\n```tsx\nstore.register({\n  id: \"save\",\n  hotkey: \"mod+S\",\n  label: \"Save Document\",\n  description: \"Save the current document to disk\",\n  category: \"file\",\n  keywords: [\"save\", \"persist\", \"write\"],\n  action: handleSave,\n})\n\n// Access all registered commands\nconst commands = store.getState().commands // Map<string, HotkeyCommand>\n```\n\n## Key state tracking\n\n```tsx\n// Check if a hotkey combo is currently held\nstore.isPressed(\"mod+S\") // boolean\n\n// Get all currently pressed keys\nstore.getCurrentlyPressed() // [\"Meta\", \"S\"]\n\n// Get physical key codes currently pressed\nstore.getPressedCodes() // [\"MetaLeft\", \"KeyS\"]\n```\n\n## Subscriptions\n\nUse `subscribe` with a selector for reactive updates:\n\n```tsx\nconst unsubscribe = store.subscribe(\n  (state) => state.pressedKeys.size,\n  (count) => console.log(`${count} keys held`),\n)\n\n// Cleanup\nunsubscribe()\n```\n\n> Selectors should return primitives for reliable change detection.\n\n## Managing commands\n\n```tsx\n// Register multiple commands at once\nstore.register([command1, command2, command3])\n\n// Remove a command\nstore.unregister(\"save\")\n\n// Clear all commands\nstore.clear()\n```\n\n## Standalone matching\n\nUse `isHotKey` to check if a keyboard event matches a hotkey string without a\nstore:\n\n```tsx\nimport { isHotKey } from \"@zag-js/hotkeys\"\n\ndocument.addEventListener(\"keydown\", (event) => {\n  if (isHotKey(\"mod+S\", event)) {\n    event.preventDefault()\n    handleSave()\n  }\n\n  // Match against multiple hotkeys\n  if (isHotKey([\"mod+S\", \"Ctrl+Shift+S\"], event)) {\n    handleSave()\n  }\n})\n```\n\n## Comparing hotkeys\n\nUse `isHotkeyEqual` to check if two hotkey strings are semantically equal. This\nresolves aliases like `mod` → `Meta`/`Control` and compares sequence steps.\n\n```tsx\nimport { isHotkeyEqual } from \"@zag-js/hotkeys\"\n\nisHotkeyEqual(\"mod+S\", \"Meta+S\") // true on Mac\nisHotkeyEqual(\"ctrl+z\", \"Control+Z\") // true\nisHotkeyEqual(\"G > H\", \"G > H\") // true\nisHotkeyEqual(\"Ctrl+G > H\", \"G > Ctrl+H\") // false (different step modifiers)\n```\n\n## Validation\n\nValidate hotkey strings at development time:\n\n```tsx\nimport { validateHotkey, assertValidHotkey } from \"@zag-js/hotkeys\"\n\nconst result = validateHotkey(\"mod+S\")\n// { valid: true, errors: [], warnings: [] }\n\nconst result2 = validateHotkey(\"Foo+S\")\n// { valid: false, errors: ['\"Foo\" is not a recognized modifier'], warnings: [] }\n\n// Throws on invalid — useful in dev\nassertValidHotkey(\"mod+S\") // ok\nassertValidHotkey(\"Foo+S\") // throws Error\n```\n\n## Formatting for display\n\nUse `formatHotkey` to render hotkey strings with platform-specific symbols:\n\n```tsx\nimport { formatHotkey } from \"@zag-js/hotkeys\"\n\nformatHotkey(\"mod+S\", { platform: \"mac\" })\n// \"⌘ S\"\n\nformatHotkey(\"mod+S\", { platform: \"windows\" })\n// \"Ctrl S\"\n\nformatHotkey(\"Alt+Shift+F\", { platform: \"mac\", style: \"symbols\" })\n// \"⌥ ⇧ F\"\n\nformatHotkey(\"G > H\", { sequenceSeparator: \" then \" })\n// \"G then H\"\n```\n\n| Option              | Type                                      | Default    | Description                        |\n| ------------------- | ----------------------------------------- | ---------- | ---------------------------------- |\n| `platform`          | `\"mac\" \\| \"windows\" \\| \"linux\" \\| \"auto\"` | `\"auto\"`   | Target platform for modifier names |\n| `style`             | `\"symbols\" \\| \"text\" \\| \"mixed\"`          | `\"mixed\"`  | Display style for keys             |\n| `separator`         | `string`                                  | `\" \"`      | Separator between modifier and key |\n| `sequenceSeparator` | `string`                                  | `\" then \"` | Separator for key sequences        |\n| `useShortNames`     | `boolean`                                 | `true`     | Abbreviate modifier names          |\n\n## Recording hotkeys\n\nUse `createHotkeyRecorder` to let users record custom key combinations.\nAutomatically handles both single chords and multi-step sequences.\n\n```tsx\nimport { createHotkeyRecorder } from \"@zag-js/hotkeys\"\n\nconst recorder = createHotkeyRecorder({ target: document })\n\nrecorder.setOptions({\n  onRecord(hotkey) {\n    console.log(\"Recorded:\", hotkey.value, hotkey.display)\n  },\n  onCancel() {\n    console.log(\"Cancelled\")\n  },\n})\n\n// Start listening for key input\nrecorder.start()\n\n// User presses Shift+K → records \"Shift+K\"\n// User presses G then H → records \"G > H\" (auto-detected sequence)\n\n// Cancel or clear\nrecorder.cancel() // stops, invokes onCancel\nrecorder.clear() // stops, clears value, invokes onClear\n\n// Stop and finalize (accepts whatever has been captured so far)\nrecorder.stop()\n\n// Read state\nrecorder.getState() // { recording: boolean, value: RecordedHotkey | null }\n\n// Subscribe to state changes\nconst unsub = recorder.subscribe((state) => {\n  console.log(state.recording, state.value?.display)\n})\n\n// Cleanup\nrecorder.destroy()\n```\n\n### Special keys during recording\n\n- **Escape** cancels recording and invokes `onCancel`\n- **Backspace / Delete** clears the recorded value and invokes `onClear`\n- **Modifier-only presses** (Shift, Ctrl, etc.) are ignored — waits for a\n  non-modifier key\n- **Dead keys** are skipped\n\n### Sequence detection\n\nAfter capturing a chord, the recorder waits for the next key within a timeout\nwindow (default 1000ms). If another key is pressed, it's appended as a sequence\nstep. If the timeout expires, the recording finalizes.\n\n```tsx\nconst recorder = createHotkeyRecorder({\n  target: document,\n  sequenceTimeoutMs: 800, // customize timeout\n})\n```\n\n### Updating options\n\nUse `setOptions` to update callbacks without recreating the recorder. This\navoids stale closure issues in React:\n\n```tsx\n// In a React component — call on every render\nrecorder.setOptions({\n  onRecord(hotkey) {\n    // Always has access to current state, no refs needed\n    saveBinding(editingId, hotkey.value)\n  },\n})\n```\n\n## Lifecycle\n\n```tsx\n// Create store\nconst store = createHotkeyStore()\n\n// Register commands (can be done before or after init)\nstore.register([...])\n\n// Attach to DOM — starts listening for keyboard events\nstore.init({ target: document })\n\n// Detach and clean up\nstore.destroy()\n```\n\nThe store can also be initialized inline:\n\n```tsx\nconst store = createHotkeyStore({ target: document })\n```\n\nThe `target` accepts `Document`, `ShadowRoot`, or any `Element` — useful for\nscoping hotkeys to a specific panel or widget.\n\n## Caveats\n\n### Symbol keys on non-US keyboard layouts\n\nWhen using **Cmd/Ctrl + symbol key** hotkeys (like `mod+/`, `mod+[`, `mod+]`),\nusers on non-US keyboard layouts may experience issues. For example, on a German\nkeyboard, `/` requires pressing Shift+7 — but some browsers report `event.key`\nincorrectly when Cmd/Ctrl is also held.\n\nThis is a known browser limitation that affects all JavaScript keyboard shortcut\nlibraries. To work around it:\n\n- **Prefer letter or digit keys** for Cmd/Ctrl combinations (`mod+K`, `mod+S`).\n  These work reliably on every layout.\n- **Use bare symbol keys** without Cmd/Ctrl for navigation shortcuts (`/` for\n  search, `?` for help) — just like GitHub and Gmail do.\n- **Register multiple commands** if you must use Cmd/Ctrl + symbol:\n\n```tsx\nstore.register([\n  { id: \"search\", hotkey: \"mod+/\", action: openSearch },\n  { id: \"search-alt\", hotkey: \"/\", action: openSearch },\n])\n```\n\n### Dead keys on international layouts\n\nAccent/compose keys on international layouts produce `event.key = \"Dead\"` and\nare automatically ignored. This prevents false matches during text composition.\n\n### macOS Option key in Firefox\n\nOn macOS, pressing Option (Alt) + a key produces composed characters (e.g.,\nOption+/ → ÷). The library handles this by falling back to physical key code\nmatching when a modifier has transformed the character output.","package":"@zag-js/hotkeys","editUrl":"https://github.com/chakra-ui/zag/edit/main/website/data/components/hotkeys.mdx"}