# Adding keyboard shortcuts to React apps with TanStack Hotkeys

**Author:** Ben Sabic

---

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](https://tanstack.com/hotkeys/latest) 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.

## How it works

`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 `Mod` modifier resolves to `Meta` (Command) on macOS and `Control` on Windows and Linux. Writing `useHotkey('Mod+S', ...)` once gives you `Cmd+S` and `Ctrl+S` without a platform check.
  
- `preventDefault` and `stopPropagation` are `true` by 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.
  
- `ignoreInputs` uses 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 and `Escape` still fire in inputs, so `Mod+S` saves 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 `useCallback` to read fresh state inside a handler.
  

## Prerequisites

- 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).
  

## 1\. Install the React adapter

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.

`npm install @tanstack/react-hotkeys`

For pnpm, Yarn, or Bun:

`pnpm add @tanstack/react-hotkeys # or yarn add @tanstack/react-hotkeys # or bun add @tanstack/react-hotkeys`

## 2\. Register your first hotkey

`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.

`import { useHotkey } from '@tanstack/react-hotkeys' function Editor() { useHotkey('Mod+S', () => { saveDocument() }) return <div>Press Cmd+S (Mac) or Ctrl+S (Windows) to save</div> }`

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:

`useHotkey('Mod+S', (event, context) => { console.log(context.hotkey) // 'Mod+S' console.log(context.parsedHotkey) // { key: 'S', meta: true, ... } })`

## 3\. Scope a hotkey to one element

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.

`import { useRef } from 'react' import { useHotkey } from '@tanstack/react-hotkeys' function Panel({ onClose }: { onClose: () => void }) { const panelRef = useRef<HTMLDivElement>(null) useHotkey('Escape', () => onClose(), { target: panelRef }) return ( <div ref={panelRef} tabIndex={0}> <p>Press Escape while focused here to close</p> </div> ) }`

The `HotkeyManager` attaches listeners per target, so scoped hotkeys don't add work to the global document listener.

## 4\. Enable a hotkey conditionally

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.

`function Modal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { useHotkey('Escape', () => onClose(), { enabled: isOpen }) if (!isOpen) return null return <div className="modal">Press Escape to close</div> }`

The hook updates `enabled` on the existing registration instead of unregistering and re-registering, so toggling it is cheap.

## 5\. Register many hotkeys at once

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:

`import { useHotkeys } from '@tanstack/react-hotkeys' function Editor() { useHotkeys( [{ hotkey: 'Mod+S', callback: () => save() }, { hotkey: 'Mod+Z', callback: () => undo() }, { hotkey: 'Escape', callback: () => close() }, ], { preventDefault: true }, ) }` 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. ## 6\. Add Vim-style key sequences `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. `import { useHotkeySequence } from '@tanstack/react-hotkeys' function VimStyleApp() { useHotkeySequence(['G', 'G'], () => scrollToTop()); useHotkeySequence(['G', 'Shift+G'], () => scrollToBottom()) return <div>Press gg to go to top, gG to go to bottom</div> }` ## 7\. Show hotkeys in your UI 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`). `import { useHotkey, formatForDisplay } from '@tanstack/react-hotkeys' function SaveButton() { useHotkey('Mod+S', () => save()) return ( <button> Save <kbd>{formatForDisplay('Mod+S')}</kbd> {/* Mac: "⌘ S" | Windows/Linux: "Ctrl+S" */} </button> ) }` ### Styling with shadcn/ui If your project uses [shadcn/ui](https://ui.shadcn.com/), the [`Kbd`](https://ui.shadcn.com/docs/components/radix/kbd) [component](https://ui.shadcn.com/docs/components/radix/kbd) gives you a styled key cap that drops in wherever you'd otherwise use `<kbd>`. Install it using the shadcn CLI:

`npx shadcn@latest add kbd`

Then wrap the formatted shortcut the same way:

`import { Kbd } from '@/components/ui/kbd' import { useHotkey, formatForDisplay } from '@tanstack/react-hotkeys' function SaveButton() { useHotkey('Mod+S', () => save()) return ( <button> Save <Kbd>{formatForDisplay('Mod+S')}</Kbd> </button> ) }`

`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:

`import { Kbd, KbdGroup } from '@/components/ui/kbd' function ShortcutChips({ hotkey }: { hotkey: string }) { // 'Mod+Shift+S' → ['⌘', '⇧', 'S'] on Mac, ['Ctrl', 'Shift', 'S'] elsewhere const parts = formatForDisplay(hotkey).split(/[\s+]+/) return ( <KbdGroup> {parts.map((key) => ( <Kbd key={key}>{key}</Kbd> ))} </KbdGroup> ) }` 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: `import { useHotkeyRegistrations } from '@tanstack/react-hotkeys' function ShortcutPalette() { const { hotkeys } = useHotkeyRegistrations() return ( <ul> {hotkeys.map((reg) => ( <li key={reg.hotkey}> <kbd>{reg.hotkey}</kbd> {reg.meta?.name && <span> — {reg.meta.name}</span>} </li> ))} </ul> ) }` Attach `meta` when you register the hotkey: `useHotkey('Mod+S', () => save(), { meta: { name: 'Save', description: 'Save the document' }, })` ## 8\. Set defaults with HotkeysProvider `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. `import { HotkeysProvider } from '@tanstack/react-hotkeys' function Root() { return ( <HotkeysProvider defaultOptions={{ hotkey: { preventDefault: true }, hotkeySequence: { timeout: 1500 }, }} > <App /> </HotkeysProvider> ) }` ## 9\. Install the devtools panel 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: `npm install @tanstack/react-devtools @tanstack/react-hotkeys-devtools` Mount the panel anywhere in your app (typically near the root, alongside any other TanStack devtools): `import { TanStackDevtools } from '@tanstack/react-devtools' import { hotkeysDevtoolsPlugin } from '@tanstack/react-hotkeys-devtools' function App() { return ( <> {/* your app */} <TanStackDevtools plugins={[hotkeysDevtoolsPlugin()]} /> </> ) }` 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`. ## Using Hotkeys with TanStack Start on Vercel TanStack Hotkeys is framework-agnostic and runs entirely in the browser. If you're building a [TanStack Start app on Vercel](https://vercel.com/docs/frameworks/full-stack/tanstack-start), 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 no `window` or `document`.
  
- The library has no build-step requirements. There's nothing to configure in `vite.config.ts` or 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.

## Troubleshooting

### `useHotkey` is registered, but the callback doesn't fire

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, pass `ignoreInputs: false` explicitly. If you want a Mod shortcut to _not_ fire in inputs, pass `ignoreInputs: 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 (has `tabIndex`) and that the user has actually focused it.
  
- **The hotkey is disabled.** Check the `enabled` option. 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.

### Two registrations for the same hotkey

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.

### `useHotkey` reads stale state

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.

### The shortcut conflicts with a browser default

`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.

## Related resources

- [TanStack Hotkeys overview](https://tanstack.com/hotkeys/latest/docs/overview)
  
- [TanStack Hotkeys React Quick Start](https://tanstack.com/hotkeys/latest/docs/framework/react/quick-start)
  
- [TanStack Hotkeys guide](https://tanstack.com/hotkeys/latest/docs/framework/react/guides/hotkeys)
  
- [TanStack Start on Vercel](https://vercel.com/docs/frameworks/full-stack/tanstack-start)
  

* * *

### Learn about TanStack Intent

---

[View full KB sitemap](/kb/sitemap.md)
