Skip to content

@tanstack/hotkeys

Type-safe, framework-agnostic keyboard hotkey management for the browser

hotkeys-core

76 linesSource

>-

TanStack Hotkeys

The library owns the keyboard. Register every shortcut with your framework adapter's hotkey API, such as useHotkey, createHotkey, injectHotkey, or @hotkey, in the component that owns the action. Import everything from the adapter package; it re-exports @tanstack/hotkeys. The adapter's skill covers its bindings.

Use the library instead of rebuilding it

Instead ofUse
A keydown listener that checks event.key, metaKey, or ctrlKeyA registered hotkey such as 'Mod+S'. Inside a widget's own key handler, matchesKeyboardEvent(event, 'Mod+Enter').
isMac checks and ⌘/Ctrl label mapsMod in the hotkey, formatForDisplay(hotkey) for labels, or formatForDisplay({ key }) for held keys
Skipping events from text fields, calling preventDefaultThe defaults: single keys and Shift/Alt chords are skipped while typing, Mod chords and Escape still fire, and matched keys are prevented
A shortcut registry, service, context, or wrapper hookThe adapter API, called where each action lives. For one list of every shortcut, load @tanstack/hotkeys#app-hotkeys.
A "shortcuts enabled" flag or topmost-dialog trackingScopes, with exclusive for dialogs
A hand-written shortcut list for a help dialogmeta: { name } and the adapter's registrations list, or app hotkeys
keydown/keyup state that tracks held keysThe adapter's held-key and hint APIs
Capturing the next key press for a settings screenThe adapter's recorder

A workaround loses what the library tracks: scopes stop suspending it, recorders stop pausing it, devtools stop listing it, and layout and platform handling disappear.

Turn hotkeys on and off

Use the first mechanism that fits:

  1. Lifetime. A hotkey registered in a component exists while that component is mounted. A dialog's Escape belongs in the dialog.
  2. enabled. A condition inside a mounted component.
  3. Scope. A group that turns on and off together, such as a dialog, pane, or tab. An exclusive scope suspends every other scope, including global hotkeys, while it is active.
  4. target. Only while focus is on one element or inside it. If nothing there can take focus, give the element tabindex="0".

When nesting scopes, stacking dialogs, or keeping some hotkeys working during an exclusive scope, read scopes.

Pitfalls

  • A disabled hotkey doesn't block the browser. With enabled: false, Mod+S opens the Save Page dialog. Add preventDefault: 'always' to keep the action blocked while the hotkey can't fire, whether it's disabled or its scope is off. Skip it for hotkeys turned off to give the key back to the browser, such as Mod+C, Mod+Z, Tab, or single keys behind a user's off switch. Don't keep the hotkey enabled and return early from the callback; the hotkey would also stay active in app hotkey lists and show its hint.
  • Modifiers match exactly. ArrowDown never fires while Shift is held, so register Shift+ArrowDown instead of checking event.shiftKey. Shift that a symbol needs is the exception: ? matches Shift+/.
  • Chords repeat while held. Set requireReset: true on toggles, or holding the keys flickers them.
  • Key names are case-sensitive. Write Escape, ArrowUp, F1, and + as the types spell them; escape, Esc, f1, and Plus never match. Only letters match in any case. Hotkeys from plain JavaScript or saved data skip the types, so check them with validateHotkey.
  • Letters follow the typed character. For position-based controls such as WASD, use physical bindings such as '[KeyW]'. Keep letters for mnemonic commands such as Mod+S.
  • Each open exclusive scope needs its own name. A reusable dialog with a fixed scope name breaks when one dialog opens another: both share one scope and both handle Escape. Build the name per instance.
  • A chord on a sequence's first key runs every time the sequence starts. With G and ['G', 'G'], the first G runs the chord. Later keys belong to the sequence. To show "waiting for the next key", read matchedStepCount from the adapter's registrations list.
  • Single-character shortcuts need an off switch. For WCAG 2.1.4, K, ?, and ['G', 'H'] need a user setting passed as enabled, or an app hotkey users can remap.
  • react-hotkeys-hook names don't carry over. useHotkeys and HotkeysProvider share names, not signatures: there's no dependency array, preventDefault is on by default, and sequences are arrays. Remove the old library, so two libraries don't handle the same keys.
  • Install only the adapter. A separately installed @tanstack/hotkeys at another version is a second copy, and scopes don't apply across copies.
  • Server-rendered labels need the user's platform. On a server, formatForDisplay renders Linux labels, because the server can't see the user's OS. Read the request's User-Agent header, pass detectPlatform(userAgent) as the platform of the adapter's provider or scope, and format labels with the adapter's formatter, such as useFormatForDisplay. Those pages then depend on the user agent, so keep them out of shared caches or add the platform to the cache key.

When writing tests for hotkeys, read testing.

API discovery

Read the installed declarations for exact signatures: the adapter's dist/*.d.ts, then the core's hotkey.types.d.ts (hotkey strings) and hotkey-manager.d.ts (HotkeyOptions). With pnpm, the core package sits beside the adapter: ls "$(realpath node_modules/@tanstack/react-hotkeys)/../hotkeys/dist", using your adapter's name.