Keyboard shortcuts seem simple at first, but edge cases can quickly lead to issues. You need Cmd+S on Mac and Ctrl+S on Windows from one handler, a search bar where K doesn't trigger the command palette, a modal that listens for Escape only when open, and a settings UI where users define their own. TanStack Hotkeys is a type-safe, framework-agnostic library that handles those cases with sensible defaults. After you write the shortcut, the library handles platform-specific input focus, conflict detection, and cleanup.
This guide walks you through installing @tanstack/react-hotkeys, registering your first hotkey with useHotkey, scoping shortcuts to specific elements, registering dynamic lists with useHotkeys, adding Vim-style sequences, displaying platform-aware shortcut labels in your UI, and wiring up the devtools panel. You'll end with a working setup that ships with your React app on Vercel.
TanStack Hotkeys is in alpha at the time of writing. The API may change before a stable release.
useHotkey registers a keyboard shortcut and a callback that fires when the user presses the matching keys. Under the hood, every hook calls into a singleton HotkeyManager that attaches one listener per target element, dedupes registrations, surfaces conflicts, and stays in sync with React's render cycle.
A few decisions about the API are worth understanding before you start, because they change what code you need to write:
- The
Modmodifier resolves toMeta(Command) on macOS andControlon Windows and Linux. WritinguseHotkey('Mod+S', ...)once gives youCmd+SandCtrl+Swithout a platform check. preventDefaultandstopPropagationaretrueby default. Most app shortcuts are meant to override the browser, so the library does that for you. Opt out per hook when you want the browser's behavior.ignoreInputsuses a smart default: single keys and Shift/Alt combos are ignored when a text input, textarea, select, or contentEditable element is focused. Mod-modifier shortcuts andEscapestill fire in inputs, soMod+Ssaves whether the user is in a textarea or not. Button-type inputs don't count as inputs for this check.- Callbacks always see the latest closure. The hook re-syncs the callback on every render, so you don't need refs or
useCallbackto read fresh state inside a handler.
- A React 18 or React 19 project. TanStack Hotkeys ships React hooks via
@tanstack/react-hotkeys. There are also adapters for Preact, Solid, Svelte, Angular, Vue, and Lit; this guide covers React. - Node.js and a package manager (npm, pnpm, Yarn, or Bun).
From the root of your project, install @tanstack/react-hotkeys. The framework package re-exports everything from the core @tanstack/hotkeys package, so you don't need to install the core separately.
For pnpm, Yarn, or Bun:
useHotkey takes a hotkey string, a callback, and an optional options object. Drop it into any component, and the shortcut is active for as long as the component is mounted.
You can pass the hotkey as a string ('Mod+Shift+Z') or as a RawHotkey object ({ key: 'Z', mod: true, shift: true }). The string form is shorter, the object form is useful when the shortcut is computed from data.
The callback receives the original KeyboardEvent plus a HotkeyCallbackContext with the parsed hotkey:
By default, hotkeys listen on document. To make a shortcut local to a panel, modal, or focused area, pass a React ref as target. The element must be focusable for it to receive keyboard events; give it tabIndex={0} if it isn't already.
The HotkeyManager attaches listeners per target, so scoped hotkeys don't add work to the global document listener.
Use the enabled option to gate a shortcut on a state. Disabled hotkeys remain registered and visible in the devtools, but their callbacks are suppressed.
The hook updates enabled on the existing registration instead of unregistering and re-registering, so toggling it is cheap.
When the list of shortcuts is dynamic (a command palette, a configurable keymap, an array of menu items), you can't call useHotkey in a loop because it breaks the rules of hooks. Use useHotkeys in situations like that. It accepts an array of definitions and an optional shared-options object:
Per-definition options override the shared options. The hook diffs the array between renders using the index plus the normalized hotkey string, so adding or removing entries automatically registers and unregisters. Reordering the array changes that identity, so reordered entries are re-registered.
useHotkeySequence registers a multi-key sequence with a configurable timeout. The default timeout is 1000 ms. You can override it per hook or globally through the provider.
Use formatForDisplay to render a shortcut the way the user's platform writes it. On macOS, the helper returns space-separated symbol labels (⌘ S); on Windows and Linux, it returns text labels joined with + (Ctrl+S).
If your project uses shadcn/ui, the Kbd component gives you a styled key cap that drops in wherever you'd otherwise use <kbd>. Install it using the shadcn CLI:
Then wrap the formatted shortcut the same way:
formatForDisplay returns the whole shortcut as a single string, so this puts every key inside one Kbd chip. If you want a separate chip per key (for example, the shadcn KbdGroup pattern, where Ctrl+B renders as two visually distinct caps), split the formatted string or build the chips from the parsed hotkey object yourself:
The split works for the formats formatForDisplay produces today. For a more durable approach, use the lower-level formatHotkey or the parsed-hotkey object exposed in the callback context to build the array yourself.
For a full shortcut palette, useHotkeyRegistrations returns the live list of registered hotkeys and sequences, including any meta you attached at registration time:
Attach meta when you register the hotkey:
HotkeysProvider lets you set defaults for every hook in the tree. Per-hook options still take priority, so the provider is for cross-cutting defaults (timeout, preventDefault, ignoreInputs) rather than per-shortcut config.
The devtools panel shows every registered hotkey, the currently held keys, the ability to trigger hotkeys for testing without pressing them, and per-registration details (target, event type, conflict behavior). Triggering hotkeys from the panel is the fastest way to verify a callback runs without having to remember the key combination or fight focus.
Install the devtools packages:
Mount the panel anywhere in your app (typically near the root, alongside any other TanStack devtools):
The framework devtools adapters return no-op implementations in production builds, so the panel won't affect your production bundle behavior. You don't need a NODE_ENV guard. If you do want devtools available in production (for example, on a staging deployment or for support tooling), React exposes an opt-in entrypoint at @tanstack/react-hotkeys-devtools/production.
TanStack Hotkeys is framework-agnostic and runs entirely in the browser.
If you're building a TanStack Start app on Vercel, there are two things to keep in mind:
- All hooks must run on the client.
useHotkey,useHotkeys,useHotkeySequence,useKeyHold,useHeldKeys, and the recorder hooks attach DOM listeners and use browser-only APIs. In a TanStack Start app, register them within components that render on the client (route components, client-only islands, or anything within an effect-style boundary). Server rendering of these hooks is a no-op since there's nowindowordocument. - The library has no build-step requirements. There's nothing to configure in
vite.config.tsor in your Vercel project settings.
For most apps, wrapping your client tree once with HotkeysProvider and calling useHotkey inside route components is all you need.
There are three common reasons for this:
- Focus is in an input. Single-key shortcuts and Shift/Alt combos are ignored when a text input, textarea, select, or contentEditable element is focused, by design. Button-type inputs (
type="button","submit","reset") are not treated as inputs for this check, so even single-key shortcuts fire when a form button has focus. If you want a single key to fire in text inputs anyway, passignoreInputs: falseexplicitly. If you want a Mod shortcut to not fire in inputs, passignoreInputs: true. - The target ref isn't focused. Scoped hotkeys (
target: someRef) only fire when the target or one of its descendants has focus. Make sure the element is focusable (hastabIndex) and that the user has actually focused it. - The hotkey is disabled. Check the
enabledoption. Disabled hotkeys stay visible in the devtools but won't fire.
The devtools panel shows the live registration list and the keys it's currently seeing, which makes these failures quick to diagnose.
By default, the library logs a warning when you register a hotkey that's already registered. If you want one of the registrations to win, set conflictBehavior: 'replace' on the registration that should take over, or 'allow' to let both fire silently. Use 'error' in tests to fail loudly on duplicates.
The hook resyncs the callback on every render, so closures inside the callback see the latest state without needing useCallback or a ref. If you're seeing stale values, confirm the component is actually rendering when the state changes (a parent might be holding a frozen prop) and that you aren't passing a memoized callback into the hook.
preventDefault is true by default, so the browser's behavior is already suppressed for most shortcuts. The browser does, however, claim a few combinations (such as Mod+W to close a tab) that web pages cannot intercept. If a shortcut you registered seems to be ignored, check it against the platform's reserved shortcuts before debugging your code.
- TanStack Hotkeys overview
- TanStack Hotkeys React Quick Start
- TanStack Hotkeys guide
- TanStack Hotkeys Core API reference
- TanStack Start on Vercel