Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Hotkeys API Reference
Hotkey Sequence API Reference
Key hold & held keys API Reference
Hotkey Recorder API Reference
Hotkey Sequence Recorder API Reference
Normalization & format API Reference

Solid Example: CreateHotkeySequenceRecorder

tsx
/* @refresh reload */
import { render } from 'solid-js/web'
import { createSignal, For, Show } from 'solid-js'
import {
  formatForDisplay,
  createHeldKeys,
  createHotkeySequence,
  createHotkeySequenceRecorder,
  HotkeysProvider,
} from '@tanstack/solid-hotkeys'
import { hotkeysDevtoolsPlugin } from '@tanstack/solid-hotkeys-devtools'
import { TanStackDevtools } from '@tanstack/solid-devtools'
import type { HotkeySequence } from '@tanstack/solid-hotkeys'
import './index.css'

const DEFAULT_SHORTCUT_ACTIONS: Record<
  string,
  { name: string; defaultSequence: HotkeySequence }
> = {
  save: { name: 'Save', defaultSequence: ['Mod+S'] },
  open: { name: 'Open (gg)', defaultSequence: ['G', 'G'] },
  new: { name: 'New (dd)', defaultSequence: ['D', 'D'] },
  close: { name: 'Close', defaultSequence: ['Mod+Shift+K'] },
  undo: { name: 'Undo (yy)', defaultSequence: ['Y', 'Y'] },
  redo: { name: 'Redo', defaultSequence: ['Mod+Shift+G'] },
}

const ACTION_ENTRIES = Object.entries(DEFAULT_SHORTCUT_ACTIONS)

function formatSeq(seq: HotkeySequence): string {
  return seq.map((h) => formatForDisplay(h)).join(' ')
}

function ShortcutListItem(props: {
  actionName: string
  sequence: HotkeySequence
  disabled: boolean
  isRecording: boolean
  liveSteps: HotkeySequence
  onEdit: () => void
  onCancel: () => void
}) {
  const heldKeys = createHeldKeys()

  return (
    <div class={`shortcut-item ${props.isRecording ? 'recording' : ''}`}>
      <div class="shortcut-item-content">
        <div class="shortcut-action">{props.actionName}</div>
        <div class="shortcut-hotkey">
          {props.isRecording ? (
            <div class="recording-indicator">
              {props.liveSteps.length > 0 ? (
                <span class="held-hotkeys">{formatSeq(props.liveSteps)}</span>
              ) : heldKeys().length > 0 ? (
                <div class="held-hotkeys">
                  {heldKeys().flatMap((key, index) =>
                    index > 0
                      ? [<span class="plus">+</span>, <kbd>{key}</kbd>]
                      : [<kbd>{key}</kbd>],
                  )}
                </div>
              ) : (
                <span class="recording-text">Press chords, then Enter…</span>
              )}
            </div>
          ) : props.disabled ? (
            <span class="no-shortcut">No shortcut</span>
          ) : (
            <kbd>{formatSeq(props.sequence)}</kbd>
          )}
        </div>
      </div>
      <div class="shortcut-actions">
        {props.isRecording ? (
          <button type="button" onClick={props.onCancel} class="cancel-button">
            Cancel
          </button>
        ) : (
          <button type="button" onClick={props.onEdit} class="edit-button">
            Edit
          </button>
        )}
      </div>
    </div>
  )
}

function App() {
  const [shortcuts, setShortcuts] = createSignal<
    Record<string, HotkeySequence | null>
  >(
    Object.fromEntries(
      Object.keys(DEFAULT_SHORTCUT_ACTIONS).map((id) => [id, null]),
    ) as Record<string, HotkeySequence | null>,
  )

  const resolveSeq = (actionId: string): HotkeySequence => {
    const s = shortcuts()[actionId]
    if (s != null) {
      return s
    }
    return DEFAULT_SHORTCUT_ACTIONS[actionId].defaultSequence
  }

  const [saveCount, setSaveCount] = createSignal(0)
  const [openCount, setOpenCount] = createSignal(0)
  const [newCount, setNewCount] = createSignal(0)
  const [closeCount, setCloseCount] = createSignal(0)
  const [undoCount, setUndoCount] = createSignal(0)
  const [redoCount, setRedoCount] = createSignal(0)
  const [recordingActionId, setRecordingActionId] = createSignal<string | null>(
    null,
  )

  const recorder = createHotkeySequenceRecorder({
    onRecord: (sequence: HotkeySequence) => {
      const id = recordingActionId()
      if (id) {
        setShortcuts((prev) => ({
          ...prev,
          [id]: sequence,
        }))
        setRecordingActionId(null)
      }
    },
    onCancel: () => setRecordingActionId(null),
    onClear: () => {
      const id = recordingActionId()
      if (id) {
        setShortcuts((prev) => ({
          ...prev,
          [id]: [],
        }))
        setRecordingActionId(null)
      }
    },
  })

  createHotkeySequence(
    () => resolveSeq('save'),
    () => setSaveCount((c) => c + 1),
    () => ({
      enabled: !recorder.isRecording() && resolveSeq('save').length > 0,
    }),
  )
  createHotkeySequence(
    () => resolveSeq('open'),
    () => setOpenCount((c) => c + 1),
    () => ({
      enabled: !recorder.isRecording() && resolveSeq('open').length > 0,
    }),
  )
  createHotkeySequence(
    () => resolveSeq('new'),
    () => setNewCount((c) => c + 1),
    () => ({
      enabled: !recorder.isRecording() && resolveSeq('new').length > 0,
    }),
  )
  createHotkeySequence(
    () => resolveSeq('close'),
    () => setCloseCount((c) => c + 1),
    () => ({
      enabled: !recorder.isRecording() && resolveSeq('close').length > 0,
    }),
  )
  createHotkeySequence(
    () => resolveSeq('undo'),
    () => setUndoCount((c) => c + 1),
    () => ({
      enabled: !recorder.isRecording() && resolveSeq('undo').length > 0,
    }),
  )
  createHotkeySequence(
    () => resolveSeq('redo'),
    () => setRedoCount((c) => c + 1),
    () => ({
      enabled: !recorder.isRecording() && resolveSeq('redo').length > 0,
    }),
  )

  const handleEdit = (actionId: string) => {
    setRecordingActionId(actionId)
    recorder.startRecording()
  }

  const handleCancel = () => {
    recorder.cancelRecording()
    setRecordingActionId(null)
  }

  const statLabel = (id: string) => {
    const seq = resolveSeq(id)
    if (seq.length === 0) {
      return '—'
    }
    return formatSeq(seq)
  }

  return (
    <div class="app">
      <header>
        <h1>Sequence shortcut settings</h1>
        <p>
          Customize Vim-style sequences. Click Edit, press each chord in order,
          then Enter to save. Escape cancels; Backspace removes the last chord
          or clears when empty.
        </p>
      </header>

      <main>
        <section class="demo-section">
          <h2>Shortcuts</h2>
          <div class="shortcuts-list">
            <For each={ACTION_ENTRIES}>
              {([actionId, action]) => (
                <ShortcutListItem
                  actionName={action.name}
                  sequence={resolveSeq(actionId)}
                  disabled={resolveSeq(actionId).length === 0}
                  liveSteps={recorder.steps()}
                  isRecording={
                    recorder.isRecording() && recordingActionId() === actionId
                  }
                  onEdit={() => handleEdit(actionId)}
                  onCancel={handleCancel}
                />
              )}
            </For>
          </div>
        </section>

        <section class="demo-section">
          <h2>Demo actions</h2>
          <p>Try your sequences within the default timeout window.</p>
          <div class="demo-stats">
            <div class="stat-item">
              <div class="stat-label">Save</div>
              <div class="stat-value">{saveCount()}</div>
              <kbd>{statLabel('save')}</kbd>
            </div>
            <div class="stat-item">
              <div class="stat-label">Open</div>
              <div class="stat-value">{openCount()}</div>
              <kbd>{statLabel('open')}</kbd>
            </div>
            <div class="stat-item">
              <div class="stat-label">New</div>
              <div class="stat-value">{newCount()}</div>
              <kbd>{statLabel('new')}</kbd>
            </div>
            <div class="stat-item">
              <div class="stat-label">Close</div>
              <div class="stat-value">{closeCount()}</div>
              <kbd>{statLabel('close')}</kbd>
            </div>
            <div class="stat-item">
              <div class="stat-label">Undo</div>
              <div class="stat-value">{undoCount()}</div>
              <kbd>{statLabel('undo')}</kbd>
            </div>
            <div class="stat-item">
              <div class="stat-label">Redo</div>
              <div class="stat-value">{redoCount()}</div>
              <kbd>{statLabel('redo')}</kbd>
            </div>
          </div>
        </section>

        <Show when={recorder.isRecording()}>
          <div class="info-box recording-notice">
            <strong>Recording sequence…</strong> Press each chord, then Enter to
            finish. Escape cancels. Backspace removes the last chord or clears.
            <Show when={recorder.steps().length > 0}>
              <div>Steps: {formatSeq(recorder.steps())}</div>
            </Show>
          </div>
        </Show>

        <section class="demo-section">
          <h2>Usage</h2>
          <pre class="code-block">{`import { createHotkeySequence, createHotkeySequenceRecorder } from '@tanstack/solid-hotkeys'

createHotkeySequence(['G', 'G'], () => goTop(), () => ({
  enabled: !recorder.isRecording(),
}))`}</pre>
        </section>
      </main>

      <TanStackDevtools plugins={[hotkeysDevtoolsPlugin()]} />
    </div>
  )
}

const root = document.getElementById('root')!
render(
  () => (
    <HotkeysProvider>
      <App />
    </HotkeysProvider>
  ),
  root,
)