Use useHotkeyRecorder to build a shortcut customization UI. Recording defaults to physical codes, producing values such as Mod+[KeyS]. Store that value directly and pass it to useHotkey. Use formatForDisplay for the label.
TanStack Hotkeys automatically suppresses registered hotkey and sequence callbacks while any recorder is active. You do not need to set enabled from isRecording. Registrations remain available for conflict detection, and recorded keys stay suppressed through repeats and key release.
Recorder options support property getters and functions returning options. Updated callbacks, validation, and recording settings apply during an active session without restarting it.
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { on } from '@ember/modifier'
import { useHotkey, useHotkeyRecorder, formatForDisplay } from '@tanstack/ember-hotkeys'
import type { Hotkey } from '@tanstack/ember-hotkeys'
export default class ShortcutSettings extends Component {
@tracked binding: Hotkey = 'Mod+S'
recorder = useHotkeyRecorder(this, {
onRecord: (hotkey) => { this.binding = hotkey },
onClear: () => { this.binding = 'Mod+S' },
})
save = () => console.log('Saved')
get label() { return formatForDisplay(this.binding) }
<template>
{{useHotkey this.binding this.save}}
<kbd>{{this.label}}</kbd>
<button type="button" {{on 'click' this.recorder.startRecording}}>Record</button>
{{#if this.recorder.isRecording}}
<p>Press a shortcut. Escape cancels; Backspace resets the binding.</p>
<button type="button" {{on 'click' this.recorder.cancelRecording}}>Cancel</button>
{{/if}}
</template>
}This example stores the replacement binding in application state and restores Mod+S when the user clears it. Cancellation leaves the saved binding unchanged.
| Property | Type | Meaning |
|---|---|---|
| isRecording | boolean | Whether a session is active. |
| recordedHotkey | Hotkey | null | The recorded binding, or null after starting, stopping, or cancelling. |
| startRecording | () => void | Start a new session. |
| stopRecording | () => void | Stop and clear recorder state without calling onRecord or onCancel. |
| cancelRecording | () => void | Stop, clear recorder state, and call onCancel. |
The state fields are reactive getters. Read recorder.isRecording and recorder.recordedHotkey where the framework tracks dependencies; destructuring them once captures a snapshot.
The default is 'code'. On macOS, Option+S producing ß records Alt+[KeyS], and Option+2 producing ™ records Alt+[Digit2]. Stored brackets preserve physical identity through serialization and registration. Existing authored strings such as Mod+S remain logical.
Set recordBy: 'key' to record the produced character. Code mode rejects an event without a usable code and never falls back to key mode. IME composition is ignored. AltGraph character entry is rejected in code mode; key mode preserves the character without synthetic Control/Alt while retaining Shift.
Receives the recorded Hotkey when the user enters a valid chord. A chord can be a single non-modifier key or a key with modifiers. Update your saved binding here.
Runs when Escape cancels a session or when you call cancelRecording(). Use it to exit an editing state without changing the saved binding.
Runs when the user presses unmodified Backspace or Delete during recording. Clearing calls only onClear; it does not call onRecord. Your application decides whether to remove the binding or restore an initial value.
Pass defaults to createHotkeysScope. The scope accepts hotkey, hotkeySequence, hotkeyRecorder, and hotkeySequenceRecorder options. Use the returned contextual helpers and recorder factories. Pass the scope through component arguments to share it with descendants; helpers and recorders still clean up with their own owners. Pass a getter for tracked defaults. Call-specific options override scope defaults, and per-definition options override common options. Omitted options use the core defaults. See shared defaults for a complete example.
Pass an options getter when configuration changes: useHotkeyRecorder(this, () => ({ recordBy: this.recordBy, onRecord: this.saveBinding })). The recorder reads current options when handling input, committing, or cancelling. Changes to recording mode, validation, conflict checks, and callbacks apply to the active session. Existing sequence steps remain unchanged when options change.
| Input | Behavior |
|---|---|
| Modifier alone | Wait for a non-modifier key. |
| Modifier plus a non-modifier key | Record the chord and finish. |
| Single non-modifier key, such as F1 | Record the key and finish. |
| Escape | Cancel. |
| Unmodified Backspace or Delete | Clear and call onClear. |
| Automatic key repeat or IME composition | Do not record a new chord. |
Recording events, repeats, and their key releases do not trigger registered hotkeys or sequences.
This defaults to true. Normal typing in inputs, textareas, selects, and contentEditable elements passes through. Escape still cancels while an input is focused. Set ignoreInputs: false to capture shortcuts from a focused input.
On macOS, Command+S becomes Mod+[KeyS]. Reusing that binding on Windows resolves Mod to Control while preserving the physical key position. Pass a platform option when detection must be overridden.
Supply these options alongside onRecord:
import type { HotkeyRecorderOptions } from '@tanstack/ember-hotkeys'
const options: HotkeyRecorderOptions = {
onRecord: (hotkey) => console.log('Accepted', hotkey),
detectConflicts: {
// Replace this with the ID from the live registration being edited.
excludeIds: ['registration-being-edited'],
target: document,
eventType: 'keydown',
},
validate: (_hotkey, { parsedHotkey }) =>
parsedHotkey.modifiers.length > 0 || 'Include a modifier.',
onReject: ({ reason, message, conflicts }) => {
console.log(reason, message, conflicts)
},
}validate returns true to accept, or false or a message to reject. Validation runs before commit. A rejected candidate leaves recording active. onReject reports missing-code, alt-graph, invalid, validation, or conflict, plus the candidate when available and conflicting views for a conflict.
detectConflicts: true checks enabled live registrations with the intended event type, defaulting to keydown, and overlapping targets, defaulting to document. Document and nested element scopes can conflict; disjoint widgets can reuse a binding. The options object supports scope: 'all', includeDisabled, and an exclude(registration) predicate.
Checks include single bindings and sequence prefixes. Source events detect physical/logical overlap on the recorded layout. This is conservative collision detection: propagation, input filtering, match priority, external listeners, unmounted routes, and other layouts can change dispatch.
For checks outside recording, call findHotkeyConflicts(bindingOrSequence, options). Without source events, it compares identities and prefixes rather than guessing which logical character a physical position produces.
For several actions, keep the bindings in an array or object and record the ID of the action being edited. On onRecord, replace that action's binding and clear the editing ID. On onCancel, clear only the editing ID. Use the plural registration API for the current list.
// In the component class:
get definitions() {
return this.shortcuts.map((shortcut) => ({
hotkey: shortcut.hotkey,
callback: () => this.runAction(shortcut.id),
options: { meta: { name: shortcut.name } },
}))
}
<template>
{{useHotkeys this.definitions}}
</template>The useHotkeyRecorder example includes multiple actions, editable names and descriptions, create/delete controls, reset and clear behavior, cancellation, and a live registry. The kitchen sink also demonstrates conflict feedback and physical versus logical recording.
Ember subscribes to the core TanStack Store through tracked state and destroys the recorder with its owner. The core HotkeyRecorder owns the recording listeners. Store the accepted binding in application state rather than relying on recorder session state as your preferences store.