Hotkeys Docs
Octane Example: UseHotkeyRecorder Sequences
import { createRoot, useState } from 'octane'
import {
HotkeysProvider,
formatForDisplay,
useHeldKeys,
useHotkeyRegistrations,
useHotkeyRecorder,
useHotkeys,
} from '@tanstack/octane-hotkeys'
import './index.css'
import type { Hotkey, HotkeySequence } from '@tanstack/octane-hotkeys'
interface Shortcut {
id: string
name: string
description: string
/** A chord or a sequence; null when unassigned */
hotkey: Hotkey | null
}
let nextId = 0
function createId(): string {
return `shortcut_${++nextId}`
}
const INITIAL_SHORTCUTS: Array<Shortcut> = [
{
id: createId(),
name: 'Save',
description: 'Save the current document',
hotkey: 'Mod+S',
},
{
id: createId(),
name: 'Open (gg)',
description: 'Open the file browser',
hotkey: ['G', 'G'],
},
{
id: createId(),
name: 'New (dd)',
description: 'Create a new document',
hotkey: ['D', 'D'],
},
{
id: createId(),
name: 'Close',
description: 'Close the current tab',
hotkey: 'Mod+Shift+K',
},
{
id: createId(),
name: 'Undo (yy)',
description: 'Undo the last action',
hotkey: ['Y', 'Y'],
},
{
id: createId(),
name: 'Redo',
description: 'Redo the last undone action',
hotkey: 'Mod+Shift+G',
},
]
function App() @{
const [shortcuts, setShortcuts] = useState<Array<Shortcut>>(
() => INITIAL_SHORTCUTS,
)
const [editingId, setEditingId] = useState<string | null>(null)
const [draftName, setDraftName] = useState('')
const [draftDescription, setDraftDescription] = useState('')
const recorder = useHotkeyRecorder({
// Record up to four chords; Enter or the fourth chord commits.
maxSteps: 4,
onRecord: (hotkey: Hotkey) => {
if (editingId) {
setShortcuts((prev) =>
prev.map((s) =>
s.id === editingId
? {
...s,
hotkey,
name: draftName,
description: draftDescription,
}
: s,
),
)
setEditingId(null)
}
},
onCancel: () => {
// If this was a brand-new shortcut with no hotkey yet, remove it
if (editingId) {
setShortcuts((prev) => {
const shortcut = prev.find((s) => s.id === editingId)
if (shortcut && shortcut.hotkey === null) {
return prev.filter((s) => s.id !== editingId)
}
return prev
})
}
setEditingId(null)
},
onClear: () => {
if (editingId) {
setShortcuts((prev) =>
prev.map((s) =>
s.id === editingId
? {
...s,
hotkey: null,
name: draftName,
description: draftDescription,
}
: s,
),
)
setEditingId(null)
}
},
})
const isRecording = recorder.isRecording
// Register every assigned shortcut, chords and sequences alike, with meta
useHotkeys(
shortcuts.flatMap((s) =>
s.hotkey === null
? []
: [
{
hotkey: s.hotkey,
callback: () => {
console.log(`${s.name} triggered:`, s.hotkey)
},
options: {
meta: { name: s.name, description: s.description },
},
},
],
),
)
const handleEdit = (id: string) => {
const shortcut = shortcuts.find((s) => s.id === id)
if (!shortcut) return
setEditingId(id)
setDraftName(shortcut.name)
setDraftDescription(shortcut.description)
recorder.startRecording()
}
const handleSaveEditing = () => {
if (editingId) {
setShortcuts((prev) =>
prev.map((s) =>
s.id === editingId
? { ...s, name: draftName, description: draftDescription }
: s,
),
)
recorder.stopRecording()
setEditingId(null)
}
}
const handleCancel = () => {
recorder.cancelRecording()
// onCancel callback handles cleanup
}
const handleDelete = (id: string) => {
setShortcuts((prev) => prev.filter((s) => s.id !== id))
}
const handleCreateNew = () => {
const newShortcut: Shortcut = {
id: createId(),
name: '',
description: '',
hotkey: null,
}
setShortcuts((prev) => [...prev, newShortcut])
setEditingId(newShortcut.id)
setDraftName('')
setDraftDescription('')
recorder.startRecording()
}
<div className="app">
<header>
<h1>Recording Sequences</h1>
<p>
<code>useHotkeyRecorder</code> with <code>maxSteps</code> records up
to that many chords. Click Edit, press each chord in order, then
press Enter (or reach four chords) to save. One chord saves as a
regular hotkey. Escape cancels; Backspace removes the last chord or
clears when empty.
</p>
</header>
<main>
<section className="demo-section">
<h2>Shortcuts</h2>
<div className="shortcuts-list">
@for (const shortcut of shortcuts; key shortcut.id) {
<ShortcutListItem shortcut={shortcut} isEditing={editingId === shortcut.id} draftName={
editingId === shortcut.id ? draftName : shortcut.name
} draftDescription={
editingId === shortcut.id
? draftDescription
: shortcut.description
} onDraftNameChange={setDraftName} onDraftDescriptionChange={setDraftDescription} liveSteps={recorder.steps} onEdit={() => handleEdit(shortcut.id)} onSave={handleSaveEditing} onCancel={handleCancel} onDelete={() => handleDelete(shortcut.id)} /> }
</div>
<button type="button" className="create-button" onClick={handleCreateNew} disabled={isRecording}>
+ Create New Shortcut
</button>
</section>
@if (recorder.isRecording) { <div className="info-box recording-notice">
<strong>Recording...</strong> Press up to four chords, then Enter
to finish. Escape cancels. Backspace removes the last chord or
clears.
@if (recorder.steps.length > 0) { <div>
Steps:{' '}
<kbd>{formatForDisplay(recorder.steps)}</kbd>
</div> }
</div> }
<RegistrationsViewer />
<section className="demo-section">
<h2>Usage</h2>
<pre className="code-block">{`import {
useHotkeys,
useHotkeyRecorder,
useHotkeyRegistrations,
} from '@tanstack/octane-hotkeys'
// Record a chord or a sequence of up to four chords
const recorder = useHotkeyRecorder({
maxSteps: 4,
onRecord: (hotkey) => save(hotkey), // 'Mod+S' or ['G', 'G']
})
// Register chords and sequences together, with meta
useHotkeys(
shortcuts.map((s) => ({
hotkey: s.hotkey,
callback: () => handleAction(s.id),
options: {
meta: { name: s.name, description: s.description },
},
})),
)
// Read all registrations reactively
const { hotkeys } = useHotkeyRegistrations()
// hotkeys[0].options.meta?.name → 'Save'
// hotkeys[0].triggerCount → 3`}</pre>
</section>
</main>
</div>
}
function RegistrationsViewer() @{
const { hotkeys } = useHotkeyRegistrations()
<section className="demo-section">
<h2>Live Registrations</h2>
<p>
This table is powered by <code>useHotkeyRegistrations()</code> — trigger
counts, names, and descriptions update in real-time as you use your
shortcuts.
</p>
<table className="registrations-table">
<thead>
<tr>
<th>Hotkey</th>
<th>Name</th>
<th>Description</th>
<th>Progress</th>
<th>Enabled</th>
<th>Triggers</th>
</tr>
</thead>
<tbody>
@for (const reg of hotkeys; key reg.id) {
<tr>
<td>
<kbd>{formatForDisplay(reg.hotkey)}</kbd>
</td>
<td>{reg.options.meta?.name ?? '—'}</td>
<td className="description-cell">
{reg.options.meta?.description ?? '—'}
</td>
<td>
{reg.kind === 'sequence'
? `${reg.matchedStepCount}/${reg.hotkey.length}`
: '—'}
</td>
<td>
<span className={
reg.options.enabled !== false ? 'status-on' : 'status-off'
}>
{reg.options.enabled !== false ? 'yes' : 'no'}
</span>
</td>
<td className="trigger-count">{reg.triggerCount}</td>
</tr> }
@if (hotkeys.length === 0) { <tr>
<td colSpan={6} className="empty-row">
No hotkeys registered
</td>
</tr> }
</tbody>
</table>
</section>
}
interface ShortcutListItemProps {
shortcut: Shortcut
isEditing: boolean
draftName: string
draftDescription: string
onDraftNameChange: (value: string) => void
onDraftDescriptionChange: (value: string) => void
liveSteps: HotkeySequence
onEdit: () => void
onSave: () => void
onCancel: () => void
onDelete: () => void
}
function ShortcutListItem({
shortcut,
isEditing,
draftName,
draftDescription,
onDraftNameChange,
onDraftDescriptionChange,
liveSteps,
onEdit,
onSave,
onCancel,
onDelete,
}: ShortcutListItemProps) @{
const heldKeys = useHeldKeys()
<div className={`shortcut-item ${isEditing ? 'recording' : ''}`}>
<div className="shortcut-item-content">
<div className="shortcut-action">
@if (isEditing) { <div className="editing-fields">
<input type="text" className="edit-input edit-name" value={draftName} onInput={(e) => onDraftNameChange(e.currentTarget.value)} placeholder="Shortcut name" />
<input type="text" className="edit-input edit-description" value={draftDescription} onInput={(e) => onDraftDescriptionChange(e.currentTarget.value)} placeholder="Description (optional)" />
</div> } @else { <>
@if (shortcut.name) { <span>{shortcut.name}</span> } @else { <span className="unnamed">Unnamed</span> }
@if (shortcut.description) { <div className="shortcut-description">
{shortcut.description}
</div> }
</> }
</div>
<div className="shortcut-hotkey">
@if (isEditing) { <div className="recording-indicator">
@if (liveSteps.length > 0) { <span className="held-hotkeys">
{formatForDisplay(liveSteps)}
</span> } @else { @if (heldKeys.length > 0) { <div className="held-hotkeys">
@for (const [index, key] of Array.from(heldKeys.entries()); key key) {
<>
@if (index > 0) { <span className="plus">+</span> }
<kbd>{key}</kbd>
</> }
</div> } @else { <span className="recording-text">
Press chords, then Enter...
</span> } }
</div> } @else { @if (shortcut.hotkey !== null) { <kbd>
{formatForDisplay(shortcut.hotkey)}
</kbd> } @else { <span className="no-shortcut">No shortcut</span> } }
</div>
</div>
<div className="shortcut-actions">
@if (isEditing) { <>
<button type="button" onClick={onSave} className="save-button">
Save
</button>
<button type="button" onClick={onCancel} className="cancel-button">
Cancel
</button>
</> } @else { <>
<button type="button" onClick={onEdit} className="edit-button">
Edit
</button>
<button type="button" onClick={onDelete} className="delete-button">
Delete
</button>
</> }
</div>
</div>
}
function Root() @{
<HotkeysProvider><App /></HotkeysProvider>
}
const root = createRoot(document.getElementById('root')!)
root.render(Root)
import.meta.hot?.dispose(() => root.unmount())