A sequence is a hotkey made of chords pressed one after another rather than together. Think Vim-style navigation, cheat codes, or multi-step commands. Sequences are ordinary hotkeys: register them with useHotkey and useHotkeys, list them with useHotkeyRegistrations, record them with useHotkeyRecorder, and format them with formatForDisplay.
Sequence steps use the same syntax as chords. For example, ['[KeyG]', '[KeyG]'] follows a physical position, while ['G', 'G'] follows the logical letter. A sequence can mix forms, such as ['Mod+[KeyK]', 'C'].
Pass an array of chords to useHotkey:
import { useHotkey } from '@tanstack/react-hotkeys'
function App() {
// Vim-style: press g then g to scroll to top
useHotkey(['G', 'G'], () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
})
}The user must press the steps in order, each within the timeout window. A one-step array such as ['Escape'] is the chord 'Escape'.
useHotkeys registers chords and sequences together, including dynamic lists:
import { useHotkeys } from '@tanstack/react-hotkeys'
useHotkeys([
{ hotkey: ['G', 'G'], callback: () => scrollToTop() },
{ hotkey: ['D', 'D'], callback: () => deleteLine(), options: { timeout: 500 } },
{ hotkey: 'Mod+S', callback: () => save() },
])Sequences accept the same options as chords, except requireReset, plus timeout:
useHotkey(['G', 'G'], callback, {
timeout: 1000, // Time allowed between steps (ms)
enabled: true, // Whether the sequence is active
})The maximum time (in milliseconds) allowed between consecutive steps. If the user takes longer than this between any two steps, the sequence resets. Defaults to 1000 (1 second).
// Fast sequence - user must type quickly
useHotkey(['D', 'D'], () => deleteLine(), { timeout: 500 })
// Slow sequence - user has more time between keys
useHotkey(['Shift+Z', 'Shift+Z'], () => forceQuit(), { timeout: 2000 })timeout is only accepted when the hotkey is a sequence. Set a default for every sequence with HotkeysProvider:
import { HotkeysProvider } from '@tanstack/react-hotkeys'
<HotkeysProvider defaultOptions={{ hotkey: { timeout: 1500 } }}>
<App />
</HotkeysProvider>These work exactly as they do for chords. Disabled sequences stay registered and visible in devtools, and meta flows to registrations, devtools, and cheat sheets. See the Hotkeys Guide.
useHotkey(['G', 'G'], () => scrollToTop(), {
enabled: isVimMode,
meta: { name: 'Go to top', description: 'Scroll to the top of the page' },
})Each step in a sequence can include modifiers:
// Ctrl+K followed by Ctrl+C (VS Code-style comment)
useHotkey(['Mod+K', 'Mod+C'], () => commentSelection())
// g then Shift+G (go to bottom, Vim-style)
useHotkey(['G', 'Shift+G'], () => scrollToBottom())You can repeat the same modifier across consecutive steps. This example follows physical R and T positions; brackets retain those positions even when the keys produce different letters:
useHotkey(['Shift+[KeyR]', 'Shift+[KeyT]'], () => doNextAction())While a sequence is in progress, modifier-only keydown events (Shift, Control, Alt, or Meta pressed alone) are ignored. They neither advance the sequence nor reset progress, so a user can tap or hold Shift between Shift+R and Shift+T.
function VimNavigation() {
useHotkeys([
{ hotkey: ['G', 'G'], callback: () => scrollToTop() },
{ hotkey: ['G', 'Shift+G'], callback: () => scrollToBottom() },
{ hotkey: ['D', 'D'], callback: () => deleteLine() },
{ hotkey: ['D', 'W'], callback: () => deleteWord() },
{ hotkey: ['C', 'I', 'W'], callback: () => changeInnerWord() },
])
}useHotkey(
[
'ArrowUp', 'ArrowUp',
'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight',
'ArrowLeft', 'ArrowRight',
'B', 'A',
],
() => enableEasterEgg(),
{ timeout: 2000 },
)formatForDisplay formats each step and joins them with sequenceSeparator, a space by default:
formatForDisplay(['Mod+K', 'Mod+C'], { platform: 'mac' }) // '⌘ K ⌘ C'
formatForDisplay(['G', 'G'], { sequenceSeparator: ' then ' }) // 'G then G'Registration views report progress for each sequence in matchedStepCount, so a help panel can show how far the user has typed. See Introspecting registrations.
The HotkeyManager tracks progress for every sequence registration. When a key is pressed:
Modifier-only events, IME composition, and automatic keydown repeats neither advance a sequence nor refresh its timeout: holding G does not complete a two-press G sequence. Exact matches take priority over weaker logical-key fallbacks.
Multiple sequences can share a prefix. Progress is tracked for each sequence independently:
useHotkeys([
{ hotkey: ['D', 'D'], callback: () => deleteLine() }, // dd
{ hotkey: ['D', 'W'], callback: () => deleteWord() }, // dw
{ hotkey: ['D', 'I', 'W'], callback: () => deleteInnerWord() }, // diw
])After pressing D, the manager waits for the next key to determine which sequence completes.
For the same target, duplicate detection compares resolved steps: modifier aliases, modifier order, and logical key casing do not create separate hotkeys. Physical and logical identities remain distinct, and a shared prefix alone is not a duplicate registration. Recorder conflict detection and app hotkey conflicts are stricter: there, a sequence also conflicts with a chord equal to its first step and with any sequence it starts.
For sequence matching without the singleton manager, use createSequenceMatcher:
import { createSequenceMatcher } from '@tanstack/react-hotkeys'
const matcher = createSequenceMatcher(['G', 'G'], { timeout: 1000 })
document.addEventListener('keydown', (e) => {
if (matcher.match(e)) {
console.log('Sequence completed!')
}
console.log('Progress:', matcher.getProgress()) // e.g., 1
})