DEV Community

Cover image for Declarative Hotkeys in Ember with @tanstack/hotkeys
Ignace Maes
Ignace Maes

Posted on

Declarative Hotkeys in Ember with @tanstack/hotkeys

Building a modern Ember "Polaris" app means reaching for lean, type-safe tools that don't come with legacy baggage. Since we’re all-in on .gts and Vite, we need a hotkey solution that feels native to TypeScript and the Glimmer lifecycle.

The move is @tanstack/hotkeys. It’s headless, tiny, and does exactly what it says on the tin.


1. The Setup

pnpm add @tanstack/hotkeys
Enter fullscreen mode Exit fullscreen mode

2. The Helper (on-hotkey.ts)

In a .gts world, we bridge the library to the component lifecycle using a class-based helper. Unlike a modifier, this helper doesn't need to be attached to a specific element—you just invoke it at the root of your template.

This setup handles the registration and teardown automatically: when the helper enters the template, the key is registered; when the template is destroyed, the listener is killed.

import Helper from '@ember/component/helper';
import type { RegisterableHotkey, HotkeyOptions } from '@tanstack/hotkeys';
import { HotkeyManager } from '@tanstack/hotkeys';

interface Signature {
  Args: {
    Positional: [hotkey: RegisterableHotkey, callback: () => void];
    Named: HotkeyOptions;
  };
  Return: void;
}

export default class OnHotkeyHelper extends Helper<Signature> {
  private unregister?: () => void;

  compute(
    [hotkey, callback]: [RegisterableHotkey, () => void],
    options: HotkeyOptions,
  ) {
    this.unregister?.();

    const manager = HotkeyManager.getInstance();
    const handle = manager.register(hotkey, () => callback(), options);

    this.unregister = () => handle.unregister();
  }

  willDestroy() {
    super.willDestroy();
    this.unregister?.();
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Usage in .gts

You invoke the helper at the top level of your template. It’s declarative: if the component is rendered, the hotkey is active.

One of the best parts of @tanstack/hotkeys is the formatForDisplay utility. It handles the annoying logic of showing ⌘K to Mac users and Ctrl+K to everyone else automatically.

import onHotkey from './helpers/on-hotkey';
import { formatForDisplay } from '@tanstack/hotkeys';

const combo = 'Mod+k';

<template>
  {{! Invoke at the root: no element needed }}
  {{onHotkey combo @onOpen}}

  <div class="search-trigger">
    <button type="button">
      Search 
      <kbd>{{formatForDisplay combo}}</kbd> 
      {{! Renders K on Mac, Ctrl+K on Windows/Linux }}
    </button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Why this is the move:

  • Native TypeScript: It’s written in TS. You get full type safety in your strict GTS templates without hunting for separate @types packages.
  • Pragmatic Defaults: It automatically ignores hotkeys from input-like elements for single keys and Shift/Alt combos, while Ctrl/Meta shortcuts still fire (since those are typically app-level commands).
  • The "Mod" Key: You don't have to check navigator.platform. Mod automatically maps to Command on Mac and Control on Windows.
  • Lifecycle Managed: Your hotkey logic is co-located with your UI. If the component is on screen, the shortcut works. If it’s gone, the listener is gone.

Why not ember-keyboard?

ember-keyboard is battle-tested, but it doesn't ship TypeScript types out of the box—a dealbreaker in a strict .gts setup.

An official @tanstack/hotkeys Ember glue addon (helper, modifier, test helpers) would be a welcome addition. Until then, the ~30 lines above get the job done.


Simple, type-safe, and zero BS. Just the way a Polaris app should be.

Top comments (2)

Collapse
 
tcjr profile image
Tom Carter

This is great! Thanks.

For testing, you can also create a basic test helper that uses the same plumbing like this:

import { type Hotkey, parseHotkey } from '@tanstack/hotkeys';

const triggerKeyPress = (combo: Hotkey) => {
  const parsed = parseHotkey(combo);
  const event = new KeyboardEvent('keydown', {
    key: parsed.key,
    code: parsed.key,
    altKey: parsed.alt,
    ctrlKey: parsed.ctrl,
    metaKey: parsed.meta,
    shiftKey: parsed.shift,
  });
  document.dispatchEvent(event);
};

export { triggerKeyPress };
Enter fullscreen mode Exit fullscreen mode

Then, in your acceptance or integration tests, invoke it with the same key combos as you register in your app:

test('onShuffle is called when Mod+S is pressed', async function (assert) {
  assert.expect(1);
  const onShuffle = () => assert.ok(true, 'onShuffle called');
  await render(
    <template>
      <GameControls @onShuffle={{onShuffle}} />
    </template>
  );
  triggerKeyPress('Mod+S');
});
Enter fullscreen mode Exit fullscreen mode

The string passed to triggerKeyPress has full type safety.

Collapse
 
ignace profile image
Ignace Maes

That's great!

I proposed an official package. If the maintainers are open for it, this would make a great addition to the package.

github.com/TanStack/hotkeys/discus...