Skip to content
Getting Started

Quick Start

This guide adds keyboard shortcuts to Ember components. If you haven't installed TanStack Hotkeys yet, see Installation.

Register a hotkey

TanStack Hotkeys needs no special setup. To register a keyboard shortcut, import the useHotkey helper into a strict-mode .gts component and invoke it in the template:

plaintext
import Component from '@glimmer/component'
import { useHotkey } from '@tanstack/ember-hotkeys'

export default class Editor extends Component {
  save = () => saveDocument()

  <template>
    {{useHotkey 'Mod+S' this.save}}
    <textarea></textarea>
  </template>
}

useHotkey is a template helper, not a JavaScript function you call. It registers the hotkey while the helper is rendered.

Mod is Command on macOS and Control on Windows and Linux, so Mod+S is the save shortcut each user already knows. The browser's Save dialog stays closed, and the shortcut still works while the user types in the textarea. Sensible Defaults explains these defaults.

That's all a hotkey needs. Shared defaults, scopes, and the other features later in this guide are optional, so add them as your app grows.

Register several hotkeys

When a component has more than one hotkey, register them together with the useHotkeys helper:

plaintext
import Component from '@glimmer/component'
import { useHotkeys } from '@tanstack/ember-hotkeys'
import type { EmberHotkeyDefinition } from '@tanstack/ember-hotkeys'

export default class Editor extends Component {
  shortcuts: Array<EmberHotkeyDefinition> = [
    { hotkey: 'Mod+S', callback: () => save() },
    { hotkey: 'Mod+Z', callback: () => undo() },
    { hotkey: 'Mod+Shift+Z', callback: () => redo() },
  ]

  <template>
    {{useHotkeys this.shortcuts}}
    <textarea></textarea>
  </template>
}

For a list that changes, use a tracked array or a getter, and the helper updates its registrations.

Sequences

Pass an array of chords to register a sequence, pressed one after another. In a template, build the array with the array helper from @ember/helper:

plaintext
{{useHotkey (array 'G' 'G') this.scrollToTop}}

Each step must follow the previous one within a second.

useHotkeys accepts chords and sequences in the same array:

ts
shortcuts: Array<EmberHotkeyDefinition> = [
  { hotkey: 'Mod+K', callback: () => openSearch() },
  { hotkey: ['G', 'H'], callback: () => goHome() },
  { hotkey: ['G', 'S'], callback: () => goToSettings() },
]

Show the shortcut

Label a button or menu item with formatForDisplay. Format the same value you register, so the label always matches the binding:

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

const saveHotkey: HotkeyChord = 'Mod+S'

export default class SaveButton extends Component {
  save = () => saveDocument()

  <template>
    {{useHotkey saveHotkey this.save}}
    <button type="button" {{on 'click' this.save}}>
      Save <kbd>{{formatForDisplay saveHotkey}}</kbd>
    </button>
  </template>
}

The label is ⌘ S on macOS and Ctrl+S on Windows and Linux. HotkeyChord checks the string, so a typo in the binding is a type error. Use Hotkey for a value that can also be a sequence.

Hotkeys follow your components

A hotkey is registered when its helper renders, and unregistered when the helper is removed from the template. Put each hotkey in the component it belongs to, so it is active only while that component is rendered:

plaintext
// settings-dialog.gts
import Component from '@glimmer/component'
import { useHotkey } from '@tanstack/ember-hotkeys'

interface SettingsDialogSignature {
  Args: { onClose: () => void }
}

export default class SettingsDialog extends Component<SettingsDialogSignature> {
  <template>
    {{! Registered only while the dialog is rendered }}
    {{useHotkey 'Escape' @onClose}}
    <div role="dialog">...</div>
  </template>
}
plaintext
// settings-button.gts
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { on } from '@ember/modifier'
import SettingsDialog from './settings-dialog'

export default class SettingsButton extends Component {
  @tracked isOpen = false
  open = () => (this.isOpen = true)
  close = () => (this.isOpen = false)

  <template>
    <button type="button" {{on 'click' this.open}}>Settings</button>
    {{#if this.isOpen}}
      <SettingsDialog @onClose={{this.close}} />
    {{/if}}
  </template>
}

For a condition that doesn't match a component, pass enabled. Named arguments are options, and tracked values keep them current:

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

When a group of hotkeys turns on and off together, put them in a scope instead. See Add defaults and scopes.

Element hotkeys

A hotkey's default target is document, so it works wherever focus is on the page. To limit it to one element, attach the onHotkey modifier to that element:

plaintext
import Component from '@glimmer/component'
import { onHotkey } from '@tanstack/ember-hotkeys'

export default class Panel extends Component {
  close = () => closePanel()

  <template>
    {{! Fires only while the panel, or something inside it, has focus }}
    <div tabindex="0" {{onHotkey 'Escape' this.close}}>...</div>
  </template>
}

onHotkeys does the same for an array of hotkeys. The element must be focusable to receive key presses, so give it a tabindex if it isn't a control.

Add defaults and scopes (optional)

So far, every hotkey uses the built-in defaults and belongs to the global scope, so it is active whenever its helper is rendered.

Ember has no provider, so pass default options to createHotkeysScope, and use the helpers and modifiers it returns:

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

export default class Editor extends Component {
  hotkeys = createHotkeysScope({ hotkey: { timeout: 1500 } })
  scrollToTop = () => window.scrollTo({ top: 0 })

  <template>
    {{this.hotkeys.useHotkey (array 'G' 'G') this.scrollToTop}}
  </template>
}

Pass it to child components, such as @hotkeys={{this.hotkeys}}, so they share the same defaults.

Hotkeys can also be grouped into scopes. Every hotkey in a scope turns on and off with it. Make a scope exclusive, and while it is active, only its hotkeys work. Use one for dialogs and popups that should own the keyboard.

Activate the scope from the hotkey manager when the component is created, release it when the component is destroyed, and pass scope to the hotkeys that belong to it:

plaintext
import Component from '@glimmer/component'
import { registerDestructor } from '@ember/destroyable'
import { getHotkeyManager, useHotkeys } from '@tanstack/ember-hotkeys'
import type Owner from '@ember/owner'
import type { EmberHotkeyDefinition } from '@tanstack/ember-hotkeys'

interface ConfirmDialogSignature {
  Args: { onConfirm: () => void; onCancel: () => void }
}

export default class ConfirmDialog extends Component<ConfirmDialogSignature> {
  shortcuts: Array<EmberHotkeyDefinition> = [
    { hotkey: 'Enter', callback: () => this.args.onConfirm() },
    { hotkey: 'Escape', callback: () => this.args.onCancel() },
  ]

  constructor(owner: Owner, args: ConfirmDialogSignature['Args']) {
    super(owner, args)
    const releaseScope = getHotkeyManager().activateScope('confirm-dialog', {
      exclusive: true,
    })
    registerDestructor(this, releaseScope)
  }

  <template>
    {{useHotkeys this.shortcuts scope='confirm-dialog'}}
    <div role="dialog">...</div>
  </template>
}

While the dialog is rendered, Enter and Escape confirm and cancel it, and every hotkey outside the dialog is suspended. When the dialog is removed, they work again.

activateScope returns a function that releases the scope. A scope stays active until every activation of it is released. Despite its name, createHotkeysScope only sets defaults for the helpers it returns. It doesn't create or activate a hotkey scope.

Next steps

Learn more about the basics:

As your app grows: