TanStack
Getting Started

Quick Start

Install @tanstack/ember-hotkeys with the installation instructions. This guide builds a save shortcut and shows the component lifecycle used by the other APIs.

Your first hotkey

Use a strict-mode .gts component. Import the template helper into that file.

plaintext
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { on } from '@ember/modifier'
import { useHotkey, formatForDisplay } from '@tanstack/ember-hotkeys'

export default class Editor extends Component {
	@tracked count = 0
	@tracked enabled = true
	save = () => this.count++
	toggle = () => { this.enabled = !this.enabled }
	label = formatForDisplay('Mod+S')

	<template>
		{{useHotkey 'Mod+S' this.save enabled=this.enabled}}
		<button type="button" {{on 'click' this.toggle}}>Toggle shortcut</button>
		<p>Press <kbd>{{this.label}}</kbd>. Saved {{this.count}} times.</p>
	</template>
}

Press Command+S on macOS or Control+S on Windows and Linux. The count increases and the browser save dialog is prevented. The toggle disables execution while preserving the registration.

Mod resolves to the primary platform modifier. Use Mod+[KeyS] instead when the shortcut must follow the physical S position.

Registration and cleanup

useHotkey, useHotkeys, useHotkeySequence, and useHotkeySequences are template helpers. Invoke them with {{...}}, not as JavaScript hooks. Tracked arguments update registrations after rendering. Removing a helper from the template unregisters its bindings.

State readers and recorders are JavaScript functions. Pass the containing component as their first argument, usually this. The adapter releases subscriptions and recorders when that owner is destroyed. State readers expose .value; recorder fields and registration arrays are reactive getters. Read those properties in templates or getters instead of destructuring an initial snapshot.

Common patterns

Multiple hotkeys

plaintext
{{useHotkey 'Mod+S' this.save}}
{{useHotkey 'Mod+Z' this.undo}}
{{useHotkey 'Mod+Shift+Z' this.redo}}

Each registration is independent. For a dynamic list, use useHotkeys as shown in the hotkeys guide.

Scoped hotkeys

Import onHotkey and attach it to the element. It handles registration, reactive options, and cleanup with the element's lifecycle.

plaintext
<div tabindex="0" {{onHotkey 'Escape' this.close enabled=this.enabled}}>
	Focus here and press Escape.
</div>

Use onHotkeys for an array of definitions. See the complete scoped example.

Conditional hotkeys

plaintext
{{useHotkey 'Mod+S' this.save enabled=this.enabled}}

Use Ember tracked state for the enabled flag.

Multi-key sequences

plaintext
{{useHotkeySequence (array 'G' 'G') this.goToTop}}

Import array from @ember/helper and useHotkeySequence from @tanstack/ember-hotkeys.

Release G between presses. Automatic key repeats do not advance sequences.

Tracking held keys and displaying hints

plaintext
import Component from '@glimmer/component'
import { useHeldKeys, useHeldKeyCodes, useKeyHold, useHotkeyHint } from '@tanstack/ember-hotkeys'

export default class KeyStatus extends Component {
	held = useHeldKeys(this)
	codes = useHeldKeyCodes(this)
	shift = useKeyHold(this, 'Shift')
	hint = useHotkeyHint(this, 'Mod+S')

	get keys() { return this.held.value.join(' + ') || 'No keys held' }
	get shiftCode() { return this.codes.value.Shift ?? 'Not held' }

	<template>
		<p>{{this.keys}}</p>
		<p>Shift position: {{this.shiftCode}}</p>
		{{#if this.shift.value}}<button type="button">Delete permanently</button>{{/if}}
		{{#if this.hint.value}}<kbd>Save</kbd>{{/if}}
	</template>
}

Use formatForDisplay(binding) for platform-specific labels. Keep the original binding in state; a display label is not a registration string.

Shared defaults

Pass defaults to createHotkeysScope. The scope accepts hotkey, hotkeySequence, hotkeyRecorder, and hotkeySequenceRecorder options. Use the returned contextual helpers, modifiers, 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.

plaintext
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { createHotkeysScope } from '@tanstack/ember-hotkeys'

export default class Editor extends Component {
  @tracked enabled = true
  hotkeys = createHotkeysScope(() => ({
    hotkey: { enabled: this.enabled, requireReset: true },
    hotkeySequence: { timeout: 1500 },
    hotkeyRecorder: { ignoreInputs: false },
    hotkeySequenceRecorder: { idleTimeoutMs: 2000 },
  }))
  save = () => console.log('Saved')
  next = () => console.log('Next')

  <template>
    {{this.hotkeys.useHotkey 'Mod+S' this.save}}
    {{this.hotkeys.useHotkey 'ArrowRight' this.next requireReset=false}}
  </template>
}

A child can receive this scope as @hotkeys={{this.hotkeys}} and call {{@hotkeys.useHotkey ...}}. To use recorder defaults, call this.hotkeys.useHotkeyRecorder(this, options) or this.hotkeys.useHotkeySequenceRecorder(this, options). The standalone imports use core defaults. The scope itself owns no registrations and needs no destroy() call.

Examples and next steps