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.
useHotkey('Mod+K', () => openSearch()) // Mod and K together
useHotkey(['G', 'H'], () => goHome()) // G, then HEvery 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:
import { useHotkey } from '@tanstack/preact-hotkeys'
function Editor() {
useHotkey('Mod+S', () => saveDocument())
return <textarea />
}The callback receives the KeyboardEvent and a context object:
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:
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:
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:
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 macOSWrite a chord as a RawHotkeyChord object when you build it from data, such as a form with a checkbox for each modifier:
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:
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:
// Submit with Enter, even while focus is in a text field
useHotkey('Enter', () => submit(), { ignoreInputs: false })| Option | Default | Description |
|---|---|---|
| enabled | true | Whether the hotkey responds to key presses. See Enable and disable hotkeys. |
| target | document | Where the hotkey listens. See Hotkey targets. |
| preventDefault | true | Stops the browser's own action, such as the Save dialog for Mod+S |
| stopPropagation | true | Stops 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' |
| capture | false | Runs before the keyboard handlers of elements inside the target. See Hotkeys run after the page's own handlers. |
| requireReset | false | Chords only. Fires once per press instead of again on each automatic repeat while the key is held. |
| timeout | 1000 | Sequences only. Milliseconds allowed between steps. |
| conflictBehavior | 'warn' | What happens when the same hotkey is already registered. See Handle duplicate hotkeys. |
| scope | The nearest provider's scope | The scope the hotkey belongs to |
| platform | Detected | The platform that decides what Mod means: 'mac', 'windows', or 'linux'. Useful in tests. |
| meta | None | A 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:
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:
useHotkey('Escape', close, { requireReset: true }) // OK
useHotkey(['G', 'G'], scrollToTop, { timeout: 500 }) // OK
useHotkey('Escape', close, { timeout: 500 }) // Type errorOptions 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:
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:
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:
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:
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:
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:
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:
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:
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:
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.