Skip to content
Guides

Register Hotkeys with useHotkey and useHotkeys in Preact

useHotkey registers a keyboard shortcut while the component that calls it is mounted, and useHotkeys registers several. Both hooks take the same hotkeys and options. To register hotkeys outside Preact components, use the hotkey manager.

Chords and sequences

A hotkey is one of two kinds:

  • A chord is keys pressed together, such as 'Mod+S', or a single key, such as 'Escape'.
  • A sequence is chords pressed one after another, such as ['G', 'H']. Write it as an array of chords.
tsx
useHotkey('Mod+K', () => openSearch()) // Mod and K together
useHotkey(['G', 'H'], () => goHome()) // G, then H

Every hook and option in this guide accepts both kinds, and most examples use chords. Sequences covers how sequences match, the time allowed between steps, and sequences that start with the same chord.

Register a hotkey

Call useHotkey with a hotkey and a callback:

tsx
import { useHotkey } from '@tanstack/preact-hotkeys'

function Editor() {
  useHotkey('Mod+S', () => saveDocument())

  return <textarea />
}

The callback receives the KeyboardEvent and a context object:

tsx
useHotkey('Mod+S', (event, context) => {
  console.log(context.hotkey) // 'Mod+S'
  console.log(context.parsedChord.modifiers) // ['Meta'] on macOS, ['Control'] elsewhere
})

The hook reads its arguments on every render, so the callback can use current props and state without a dependency array:

tsx
import { useState } from 'preact/hooks'

function Counter() {
  const [count, setCount] = useState(0)

  // Logs the current count, not the count from the first render
  useHotkey('Mod+Shift+C', () => console.log(count))

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>
}

The hotkey and its options can change between renders too. When only the options change, the hook updates the existing registration. When the hotkey or its target changes, the hook replaces the registration:

tsx
function Editor({ saveHotkey, readOnly }: EditorProps) {
  useHotkey(saveHotkey, () => save(), { enabled: !readOnly })
}

That lets you register a hotkey the user chose, such as one from the hotkey recorder. To let users change hotkeys across your app and save their choices, see App Hotkeys.

Write a chord as a string or an object

Write most chords as strings. Your editor autocompletes them, and TypeScript rejects a string that isn't a valid chord:

tsx
useHotkey('Escape', () => close())
useHotkey('Mod+S', () => save()) // Command+S on macOS, Control+S elsewhere
useHotkey('Mod+Alt+Shift+K', () => openCommandLog()) // Modifiers in order: Mod or Control, Alt, Shift, Meta
useHotkey('[KeyW]', () => moveUp()) // The W position, whatever the layout types there

useHotkey('Shift+Mod+S', () => saveAs()) // Type error: Mod comes first
useHotkey('Mod+Meta+S', () => saveAs()) // Type error: Mod is already Meta on macOS

Write a chord as a RawHotkeyChord object when you build it from data, such as a form with a checkbox for each modifier:

tsx
useHotkey({ key: 'S', mod: true, shift: true }, () => saveAs()) // Same as 'Mod+Shift+S'
useHotkey({ code: 'KeyW' }, () => moveUp()) // Same as '[KeyW]'

useHotkey(
  { key: binding.key, mod: binding.mod, shift: binding.shift },
  () => runAction(),
)

Sequence steps can be strings, objects, or a mix:

tsx
useHotkey(['G', { key: 'H', shift: true }], () => goToHistory())

The hook registers every chord and sequence step as a string, so context.hotkey and devtools show 'Mod+Shift+S' for the first object above. Match a key's position instead explains codes such as [KeyW].

Hotkey options

Pass options as the third argument:

tsx
// Submit with Enter, even while focus is in a text field
useHotkey('Enter', () => submit(), { ignoreInputs: false })
OptionDefaultDescription
enabledtrueWhether the hotkey responds to key presses. See Enable and disable hotkeys.
targetdocumentWhere the hotkey listens. See Hotkey targets.
preventDefaulttrueStops the browser's own action, such as the Save dialog for Mod+S
stopPropagationtrueStops the key press from reaching other listeners
ignoreInputs'auto'Skips key presses that type in a text field or operate a focused control. See Typing in inputs does not trigger shortcuts.
eventType'keydown'Fires on 'keydown' or 'keyup'
capturefalseRuns before the keyboard handlers of elements inside the target. See Hotkeys run after the page's own handlers.
requireResetfalseChords only. Fires once per press instead of again on each automatic repeat while the key is held.
timeout1000Sequences only. Milliseconds allowed between steps.
conflictBehavior'warn'What happens when the same hotkey is already registered. See Handle duplicate hotkeys.
scopeThe nearest provider's scopeThe scope the hotkey belongs to
platformDetectedThe platform that decides what Mod means: 'mac', 'windows', or 'linux'. Useful in tests.
metaNoneA name, a description, and a group. See Describe hotkeys with meta.

The defaults suit most hotkeys. Sensible Defaults explains why each one was chosen and when to change it. The UseHotkeyOptions reference lists the types.

To change a default for every hotkey in your app, pass defaultOptions to a HotkeysProvider at the root. Options passed to a hook override the provider's defaults:

tsx
import { HotkeysProvider } from '@tanstack/preact-hotkeys'

function Root() {
  return (
    <HotkeysProvider
      defaultOptions={{ hotkey: { requireReset: true, timeout: 1500 } }}
    >
      <App />
    </HotkeysProvider>
  )
}

The provider is optional. Without one, every hotkey uses the built-in defaults in the table. See HotkeysProvider and Scopes for nested defaults and for grouping hotkeys into scopes.

Chord and sequence options

Most options apply to both kinds of hotkey. requireReset applies only to chords, and timeout only to sequences. The option types follow the hotkey you pass, so TypeScript rejects options for the other kind:

tsx
useHotkey('Escape', close, { requireReset: true }) // OK
useHotkey(['G', 'G'], scrollToTop, { timeout: 500 }) // OK
useHotkey('Escape', close, { timeout: 500 }) // Type error

Options that apply to many hotkeys at once, such as provider defaults, apply each of these to the matching kind only. In the provider example above, requireReset affects chords, and timeout affects sequences.

When the kind isn't known until runtime, such as a Hotkey loaded from settings, the hook accepts only the options that both kinds share.

Register multiple hotkeys

When a component has more than one hotkey, use useHotkeys. It takes an array of definitions, each with a hotkey, a callback, and optional options:

tsx
import { useHotkeys } from '@tanstack/preact-hotkeys'

function Editor() {
  useHotkeys([
    { hotkey: 'Mod+S', callback: () => save() },
    { hotkey: 'Mod+Z', callback: () => undo() },
    { hotkey: 'Mod+Shift+Z', callback: () => redo() },
    { hotkey: ['G', 'G'], callback: () => scrollToTop(), options: { timeout: 500 } },
  ])

  return <textarea />
}

Chords and sequences can share one array. Each definition's options are typed by its hotkey, as they are for useHotkey.

A callback in the array receives a context that can belong to either kind of hotkey. Check context.kind before reading context.parsedChord:

tsx
useHotkeys([
  {
    hotkey: 'Mod+S',
    callback: (event, context) => {
      if (context.kind === 'chord') console.log(context.parsedChord.modifiers)
    },
  },
])

Share options

Pass options for every definition as the second argument. A definition's own options override them:

tsx
import { useRef } from 'preact/hooks'

function RichTextEditor() {
  const editorRef = useRef<HTMLDivElement>(null)

  useHotkeys(
    [
      { hotkey: 'Mod+B', callback: () => toggleBold() },
      { hotkey: 'Mod+I', callback: () => toggleItalic() },
      { hotkey: 'Mod+Z', callback: () => undo(), options: { enabled: canUndo } },
    ],
    { target: editorRef },
  )

  return <div ref={editorRef} contentEditable />
}

The shared target makes all three hotkeys listen on the editor only. Hotkey targets explains targets.

Options merge in this order: provider defaults, then the shared options, then each definition's options.

Build the list from data

Hooks can't run in loops, so use useHotkeys for any list whose length can change:

tsx
function MenuShortcuts({ items }: { items: Array<MenuItem> }) {
  useHotkeys(
    items.map((item) => ({
      hotkey: item.shortcut,
      callback: item.action,
      options: { enabled: item.enabled },
    })),
  )
}

The hook compares the array between renders by position and hotkey. It registers new hotkeys and unregisters removed ones. Reordering the array registers the moved hotkeys again.

Enable and disable hotkeys

A hotkey is on while its component is mounted. Most hotkeys need no other on and off logic, so put each one in the component it belongs to:

tsx
function SettingsDialog({ onClose }: { onClose: () => void }) {
  // Registered only while the dialog is mounted
  useHotkey('Escape', onClose)

  return <div role="dialog">...</div>
}

For a condition that doesn't match a component, pass enabled:

tsx
useHotkey('Mod+S', () => save(), { enabled: hasUnsavedChanges })

A disabled hotkey stays registered, so devtools still list it, and changing enabled doesn't register it again. While disabled, it ignores key presses completely and doesn't call preventDefault. Pressing Mod+S while the hotkey above is disabled opens the browser's Save dialog. To keep the dialog closed, leave the hotkey enabled and return early from the callback:

tsx
useHotkey('Mod+S', () => {
  if (!hasUnsavedChanges) return
  save()
})

enabled works the same way for sequences. A disabled sequence ignores its steps.

Single-key hotkeys, such as K, need a setting that lets users turn them off. Pass that setting as enabled. See Single-key shortcuts need an off switch.

When a group of hotkeys turns on and off together, use scopes instead of passing enabled to each one. A scope can turn off the hotkeys in a tab panel that stays mounted, or suspend the rest of the page while a dialog is open.

Hotkey targets

A hotkey listens on document by default, so it works wherever focus is on the page. Set target to a ref, an element, document, or window to listen somewhere else:

tsx
import { useRef } from 'preact/hooks'

function Panel() {
  const panelRef = useRef<HTMLDivElement>(null)

  // Fires only while the panel, or something inside it, has focus
  useHotkey('Escape', () => closePanel(), { target: panelRef })

  return (
    <div ref={panelRef} tabIndex={0}>
      Panel content
    </div>
  )
}

Note

An element receives key presses only while it, or something inside it, has focus. Give it a tabIndex if it isn't focusable already.

While focus is inside the element, its hotkeys run before hotkeys on document. With the default stopPropagation: true, a document hotkey for the same key doesn't run at all. A sequence with a target advances only on key presses inside the target.

A target is not a scope

A target and a scope both limit when a hotkey works, in different ways:

  • A target decides which key presses the hotkey receives, based on where focus is.
  • A scope decides whether the hotkey is on, based on which parts of your app are active, such as an open dialog. Focus doesn't matter.

Use a target for hotkeys that belong to a focusable element, such as a list or a code editor. Use a scope for hotkeys that belong to a part of your app, whether or not it has focus. A hotkey can have both.

Handle duplicate hotkeys

When you register a hotkey that is already registered on the same target and in the same scope, TanStack Hotkeys logs a warning, and both callbacks run. Set conflictBehavior to change that:

  • 'warn' (default) logs a warning and keeps both registrations.
  • 'error' throws an error.
  • 'replace' unregisters the existing registration.
  • 'allow' keeps both without a warning.

The comparison resolves modifiers first, so Mod+S and Meta+S are duplicates on macOS. Two sequences are duplicates when all their steps match. Sequences that only start with the same chord, such as ['G', 'H'] and ['G', 'S'], are not duplicates.

The same hotkey in two different scopes is not a duplicate either. When both scopes are active, the scope with higher precedence handles the key. See Nested scopes win.

Describe hotkeys with meta

Give a hotkey a name, a description, and a group with meta:

tsx
useHotkey('Mod+S', () => save(), {
  meta: { name: 'Save', description: 'Save the document', group: 'File' },
})

meta doesn't change how a hotkey behaves. Devtools show it, and you can build a list of your app's shortcuts from it. See Cheat Sheet UI Helpers.