Skip to content
Guides

Record Custom Hotkeys with injectHotkeyRecorder in Angular

injectHotkeyRecorder lets users choose a hotkey by pressing it, as shortcut editors in system settings and IDEs do. You only need it if users can change your app's hotkeys. To save their choices and list them in a cheat sheet, pair it with app hotkeys.

While any recorder is listening, registered hotkeys don't fire, so pressing Mod+S to record it doesn't also save. You don't need to disable them yourself.

Record a chord

ts
import { Component } from '@angular/core'
import { formatForDisplay, injectHotkeyRecorder } from '@tanstack/angular-hotkeys'

@Component({
  selector: 'app-shortcut-recorder',
  template: `
    <button (click)="recorder.startRecording()">
      {{
        recorder.isRecording()
          ? 'Press a key combination...'
          : recorder.recordedHotkey()
            ? format(recorder.recordedHotkey()!)
            : 'Click to record'
      }}
    </button>
    @if (recorder.isRecording()) {
      <button (click)="recorder.cancelRecording()">Cancel</button>
    }
  `,
})
export class ShortcutRecorderComponent {
  readonly format = formatForDisplay
  readonly recorder = injectHotkeyRecorder({
    onRecord: (hotkey) => console.log('Recorded:', hotkey), // e.g. 'Mod+Shift+[KeyS]'
  })
}

The recorder records the key's position by default, so Option+S on macOS records Alt+[KeyS], even though it types ß. It records Mod rather than Command or Control, so the hotkey works on every platform. Pass the result straight to injectHotkey, and label it with formatForDisplay. Recording captures what the user pressed explains these defaults. Set recordBy: 'key' to record the character instead.

injectHotkeyRecorder returns the recorder's state and controls. State members are signals:

MemberDescription
isRecording()Whether the recorder is listening
steps()Chords recorded so far in a sequence recording
recordedHotkey()The hotkey from the last recording, or null after a cancel or clear
startRecording()Starts listening
stopRecording()Stops listening without calling onRecord or onCancel
cancelRecording()Stops listening and calls onCancel
commitRecording()Commits the steps recorded so far

Record a sequence

Set maxSteps: Infinity to let users record a sequence. Each chord adds a step, and the recording commits when the user presses Enter, or after idleTimeoutMs without a new step:

ts
@Component({
  selector: 'app-sequence-recorder',
  template: `
    <button (click)="recorder.startRecording()">
      {{
        recorder.isRecording()
          ? format(recorder.steps()) || 'Press chords, then Enter...'
          : recorder.recordedHotkey()
            ? format(recorder.recordedHotkey()!)
            : 'Click to record'
      }}
    </button>
  `,
})
export class SequenceRecorderComponent {
  readonly format = formatForDisplay
  readonly recorder = injectHotkeyRecorder({
    maxSteps: Infinity,
    onRecord: (hotkey) => console.log('Recorded:', hotkey), // 'Mod+[KeyS]' or ['[KeyG]', '[KeyG]']
  })
}

A one-step recording commits as a chord, so the result can always go straight to injectHotkey. With maxSteps: Infinity, onRecord and validate receive a Hotkey, which can be a chord or a sequence. See Sequences for how sequences match.

Options can also be a getter, such as injectHotkeyRecorder(() => ({ maxSteps: Infinity, recordBy: this.mode(), onRecord })), so the recorder follows signals.

Keys the recorder handles

KeyWhat it does
A modifier alone, such as ShiftWaits for a key to go with it
A key, with or without modifiersRecords a chord, such as Mod+[KeyS] or [F1]
EnterCommits a sequence recording. With maxSteps: 1 or commitKeys: 'none', records Enter like any other key.
EscapeCancels the recording and calls onCancel
Backspace or Delete, without modifiersRemoves the last step. With no steps, stops recording and calls onClear.

Repeats and IME composition never add a step. onClear doesn't change anything on its own, so your app decides whether clearing removes a hotkey or restores its default.

By default, the recorder ignores typing in text fields, so other fields on the page keep working while it listens. Escape still cancels. Set ignoreInputs: false to record from inside a text field.

Validate and check conflicts

Reject hotkeys you don't want

Pass validate to accept or reject each recording before it commits. Return true to accept, or false or a message to reject:

ts
injectHotkeyRecorder({
  validate: (_hotkey, { parsedChord }) =>
    parsedChord.modifiers.length > 0 || 'Include a modifier.',
  onReject: ({ message }) => setError(message),
  onRecord: (hotkey) => saveHotkey(hotkey),
})

The second argument has the event that recorded the chord and parsedChord, with Mod resolved. With maxSteps: Infinity, a recording can be a chord or a sequence, so check kind first. A sequence's context has events and parsedSequence, with one entry per step:

ts
injectHotkeyRecorder({
  maxSteps: Infinity,
  validate: (_hotkey, context) =>
    context.kind === 'sequence' ||
    context.parsedChord.modifiers.length > 0 ||
    'A single chord needs a modifier.',
  onRecord: (hotkey) => saveHotkey(hotkey),
})

A rejected recording keeps listening, so the user can try again. A rejected sequence recording keeps its steps, so the user can edit them with Backspace.

Check for conflicts

Set detectConflicts: true to reject a hotkey that's already registered:

ts
injectHotkeyRecorder({
  detectConflicts: true,
  onReject: ({ reason, message, conflicts }) => {
    // reason is 'conflict', and conflicts lists the registrations it collides with
    setError(message)
  },
  onRecord: (hotkey) => saveHotkey(hotkey),
})

By default, the check compares the recording with every enabled keydown hotkey on the page, in every scope. A recorded chord conflicts with a sequence that starts with it, and a recorded sequence conflicts with a chord equal to its first step. Pass an object instead of true to change what's checked:

ts
detectConflicts: {
  excludeIds: [registration.id], // Skip the hotkey being changed
  target: panelElement, // Only hotkeys on this element, inside it, or around it
  scope: 'all', // Ignore target and check hotkeys on every element. Unrelated to hotkey scopes.
  eventType: 'keyup', // Check keyup hotkeys instead
  includeDisabled: true, // Include disabled hotkeys
  exclude: (registration) => registration.options.scope === 'vim', // Skip one hotkey scope
}

A conflict check finds hotkeys that could collide. It can't promise which callback a key press would run, because propagation, typing in fields, other listeners, and pages that haven't rendered all affect that. To check app hotkeys, including ones on pages that haven't rendered, use findConflicts from app hotkeys instead.

onReject receives a reason: 'conflict', 'validation', 'invalid', 'missing-code' for a key press with no physical code, or 'alt-graph' for AltGr typing a character. It also receives a message, the rejected hotkey when there is one, and the conflicts for a conflict.

To check a hotkey outside a recording, call findHotkeyConflicts(hotkey, options). It takes the same options, and returns registration views.

Build a shortcut settings screen

For an app-wide settings screen, declare your hotkeys as app hotkeys, and save each recording with setCustomHotkey. That keeps cheat sheets, conflict checks, and saved choices in one place. Change hotkeys shows a settings row built that way.

For a few hotkeys in one component, signals are enough:

ts
import { Component, signal } from '@angular/core'
import {
  formatForDisplay,
  injectHotkeyRecorder,
  injectHotkeys,
} from '@tanstack/angular-hotkeys'
import type { Hotkey } from '@tanstack/angular-hotkeys'

type Action = 'save' | 'undo' | 'search'

@Component({
  selector: 'app-shortcut-settings',
  template: `
    @for (entry of entries(); track entry[0]) {
      <button (click)="edit(entry[0])">
        {{ entry[0] }}:
        {{ editing() === entry[0] && recorder.isRecording() ? 'Press keys...' : format(entry[1]) }}
      </button>
    }
  `,
})
export class ShortcutSettingsComponent {
  readonly format = formatForDisplay
  readonly shortcuts = signal<Record<Action, Hotkey>>({
    save: 'Mod+S',
    undo: 'Mod+Z',
    search: ['G', 'S'],
  })
  readonly editing = signal<Action | null>(null)
  readonly entries = () => Object.entries(this.shortcuts()) as Array<[Action, Hotkey]>

  readonly recorder = injectHotkeyRecorder({
    maxSteps: Infinity,
    onRecord: (hotkey) => {
      const action = this.editing()
      if (action) this.shortcuts.update((prev) => ({ ...prev, [action]: hotkey }))
      this.editing.set(null)
    },
    onCancel: () => this.editing.set(null),
  })

  constructor() {
    // Register each action with its current hotkey
    injectHotkeys(() =>
      this.entries().map(([action, hotkey]) => ({
        hotkey,
        callback: () => this.run(action),
      })),
    )
  }

  edit(action: Action) {
    this.editing.set(action)
    this.recorder.startRecording()
  }

  run(action: Action) {
    /* ... */
  }
}

Recorder options

OptionDefaultDescription
onRecordRequiredCalled with the recorded hotkey when a recording commits
onCancelNoneCalled when the user presses Escape or you call cancelRecording()
onClearNoneCalled when the user presses Backspace or Delete with nothing recorded
maxSteps11 records a chord. Infinity records a sequence.
commitKeys'enter'With maxSteps: Infinity, whether plain Enter commits ('enter') or records as a step ('none')
idleTimeoutMsNoneWith maxSteps: Infinity, commits after this many milliseconds without a new step. The timer starts after the first chord.
recordBy'code'Records the key's position ('code') or the character it types ('key')
ignoreInputstrueLets typing in text fields pass through while recording
validateNoneAccepts or rejects a recording. See Reject hotkeys you don't want.
detectConflictsfalseRejects a recording that collides with a registered hotkey. See Check for conflicts.
onRejectNoneCalled with the reason when a recording is rejected
platformDetectedThe platform that decides what Mod means

To change the defaults for every recorder, pass hotkeyRecorder options to provideHotkeys. See Set default options. The injectHotkeyRecorder reference lists the types.

The injectHotkeyRecorder example records chords, and the sequences example records sequences.