Hotkeys Docs
Octane Example: UseHotkey Sequences
import { createRoot, useState } from 'octane'
import { HotkeysProvider, useHotkey } from '@tanstack/octane-hotkeys'
import './index.css'
function App() @{
const [lastSequence, setLastSequence] = useState<string | null>(null)
const [history, setHistory] = useState<Array<string>>([])
const [helloSequenceEnabled, setHelloSequenceEnabled] = useState(true)
const addToHistory = (action: string) => {
setLastSequence(action)
setHistory((h) => [...h.slice(-9), action])
}
useHotkey(['G', 'G'], () => addToHistory('gg → Go to top'))
useHotkey(['Shift+G'], () => addToHistory('G → Go to bottom'))
useHotkey(['D', 'D'], () => addToHistory('dd → Delete line'))
useHotkey(['Y', 'Y'], () => addToHistory('yy → Yank (copy) line'))
useHotkey(['D', 'W'], () => addToHistory('dw → Delete word'))
useHotkey(['C', 'I', 'W'], () =>
addToHistory('ciw → Change inner word'),
)
useHotkey(
['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown'],
() => addToHistory('↑↑↓↓ → Konami code (partial)'),
{ timeout: 1500 },
)
useHotkey(
['ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight'],
() => addToHistory('←→←→ → Side to side!'),
{ timeout: 1500 },
)
useHotkey(
['H', 'E', 'L', 'L', 'O'],
() => addToHistory('hello → Hello World!'),
{ enabled: helloSequenceEnabled },
)
useHotkey(['Shift+[KeyR]', 'Shift+[KeyT]'], () =>
addToHistory('⇧R ⇧T → Chained Shift+letter (2 steps)'),
)
useHotkey('Escape', () => {
setLastSequence(null)
setHistory([])
})
<div className="app">
<header>
<h1>Sequences with useHotkey</h1>
<p>
Pass an array of chords to <code>useHotkey</code> to register a
multi-key sequence (like Vim commands). Keys must be pressed within
the timeout window (default: 1000ms).
</p>
</header>
<main>
<section className="demo-section">
<h2>Vim-Style Commands</h2>
<table className="sequence-table">
<thead>
<tr>
<th>Sequence</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<kbd>g</kbd> <kbd>g</kbd>
</td>
<td>Go to top</td>
</tr>
<tr>
<td>
<kbd>G</kbd> (Shift+G)
</td>
<td>Go to bottom</td>
</tr>
<tr>
<td>
<kbd>d</kbd> <kbd>d</kbd>
</td>
<td>Delete line</td>
</tr>
<tr>
<td>
<kbd>y</kbd> <kbd>y</kbd>
</td>
<td>Yank (copy) line</td>
</tr>
<tr>
<td>
<kbd>d</kbd> <kbd>w</kbd>
</td>
<td>Delete word</td>
</tr>
<tr>
<td>
<kbd>c</kbd> <kbd>i</kbd> <kbd>w</kbd>
</td>
<td>Change inner word</td>
</tr>
</tbody>
</table>
</section>
<section className="demo-section">
<h2>Fun Sequences</h2>
<div className="fun-sequences">
<div className="sequence-card">
<h3>Konami Code (Partial)</h3>
<p>
<kbd>↑</kbd> <kbd>↑</kbd> <kbd>↓</kbd> <kbd>↓</kbd>
</p>
<span className="hint">Use arrow keys within 1.5 seconds</span>
</div>
<div className="sequence-card">
<h3>Side to Side</h3>
<p>
<kbd>←</kbd> <kbd>→</kbd> <kbd>←</kbd> <kbd>→</kbd>
</p>
<span className="hint">Arrow keys within 1.5 seconds</span>
</div>
<div className="sequence-card">
<h3>Spell It Out</h3>
<p>
<kbd>h</kbd> <kbd>e</kbd> <kbd>l</kbd> <kbd>l</kbd> <kbd>o</kbd>
</p>
<span className="hint">Type "hello" quickly</span>
<p className="sequence-toggle-status">
This sequence is{' '}
<strong>{helloSequenceEnabled ? 'enabled' : 'disabled'}</strong>
.
</p>
<button type="button" onClick={() => setHelloSequenceEnabled((v) => !v)}>
{helloSequenceEnabled ? 'Disable' : 'Enable'} sequence
</button>
</div>
</div>
</section>
@if (lastSequence) { <div className="info-box success">
<strong>Triggered:</strong> {lastSequence}
</div> }
<section className="demo-section">
<h2>Input handling</h2>
<p>
Sequences are not detected when typing in text inputs, textareas,
selects, or contenteditable elements. Button-type inputs (
<code>type="button"</code>, <code>submit</code>, <code>reset</code>)
still receive sequences. Focus the input below and try <kbd>g</kbd>{' '}
<kbd>g</kbd> or <kbd>h</kbd>
<kbd>e</kbd>
<kbd>l</kbd>
<kbd>l</kbd>
<kbd>o</kbd> — nothing will trigger. Click outside to try again.
</p>
<input type="text" className="demo-input" placeholder="Focus here – sequences won't trigger while typing..." />
</section>
<section className="demo-section">
<h2>Chained Shift+letter sequences</h2>
<p>
These steps use physical R and T positions (<code>[KeyR]</code> and{' '}
<code>[KeyT]</code>), independent of the characters they produce.
Hold <kbd>Shift</kbd> and press each position. You can press{' '}
<kbd>Shift</kbd> alone between steps—those modifier-only presses do
not reset progress, so the next chord still counts. Holding a key
does not count as additional steps.
</p>
<table className="sequence-table">
<thead>
<tr>
<th>Sequence</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<kbd>Shift</kbd>+<kbd>r</kbd> then <kbd>Shift</kbd>+
<kbd>t</kbd>
</td>
<td>Chained Shift+letter (2 steps)</td>
</tr>
</tbody>
</table>
</section>
<section className="demo-section">
<h2>Usage</h2>
<pre className="code-block">{`import { useHotkey } from '@tanstack/octane-hotkeys'
function VimEditor() @{
// Basic sequence
useHotkey(['G', 'G'], () => {
scrollToTop()
})
// With custom timeout (1.5 seconds)
useHotkey(
['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown'],
() => activateCheatMode(),
{ timeout: 1500 }
)
// Three-key sequence
useHotkey(['C', 'I', 'W'], () => {
changeInnerWord()
})
// Same modifier on each step (Shift+letter, then Shift+letter)
useHotkey(['Shift+[KeyR]', 'Shift+[KeyT]'], () => {
doSomething()
})
}`}</pre>
</section>
@if (history.length > 0) { <section className="demo-section">
<h2>History</h2>
<ul className="history-list">
@for (const [i, item] of Array.from(history.entries()); key i) {
<li>{item}</li> }
</ul>
<button onClick={() => setHistory([])}>Clear History</button>
</section> }
<p className="hint">
Press <kbd>Escape</kbd> to clear history
</p>
</main>
</div>
}
function Root() @{
<HotkeysProvider><App /></HotkeysProvider>
}
const root = createRoot(document.getElementById('root')!)
root.render(Root)
import.meta.hot?.dispose(() => root.unmount())