コマンドパレット

ページ・記事・アクションを検索できます。

TanStack Hotkeys を導入した

2026/3/31

このポートフォリオには cmdk ベースのコマンドパレットがあり、Cmd+K で開けます。しかし、それ以外のキーボード操作は何もありませんでした。テーマ切替はマウスクリックのみ、ブログ記事間の移動はブラウザバックのみ。開発者向けポートフォリオとしては物足りません。

Cmd+K は生の addEventListener で実装していたので、@tanstack/react-hotkeys に移行しつつショートカットを増やすことにしました。API がシンプルで、TanStack エコシステムとの一貫性もあります。

@tanstack/react-hotkeys の設計を理解する

実装の前に、ライブラリの内部動作を調べました。

入力要素の自動無視

@tanstack/hotkeys のコアには shouldIgnoreInputEvent という関数があります。inputtextareacontentEditable 要素にフォーカスがあるとき、ホットキーの発火を抑制します。この挙動はキーの種類によって自動的に切り替わります。

  • 修飾キー付きMod+K など): 入力要素内でも発火する(ignoreInputs: false
  • Escape: 入力要素内でも発火する
  • 単一キーTArrowLeft など): 入力要素内では無視される(ignoreInputs: true

この自動判定のおかげで、コマンドパレットの検索欄に t と打ったときにテーマが切り替わる、といった事故が起きません。手動で e.target.tagName をチェックする必要がないのは大きいです。

preventDefault のデフォルト有効

生の addEventListener では e.preventDefault() を手動で呼ぶ必要がありました。@tanstack/react-hotkeys ではデフォルトで preventDefault: true になっています。コールバックがすっきりします。

キー名の正規化

キー名は KeyboardEvent.key の標準値に正規化されます。tTleftArrowLeftescEscape。さらに event.code へのフォールバックがあり、macOS で Option キーを押しながらの入力や Dead Key イベントでも正しくマッチします。

実装した5つのショートカット

実際の動作の一部です。コマンドパレットの起動やテーマ切替がキーボードだけで完結します。

1. Cmd+K の移行

生の addEventListener から useHotkey への移行です。10行のボイラープレートが1行になりました。

// Before: useEffect + addEventListener + cleanup
useEffect(() => {
  function onKeyDown(e: KeyboardEvent) {
    if ((e.metaKey || e.ctrlKey) && e.key === "k") {
      e.preventDefault();
      setOpen((prev) => !prev);
    }
  }
  document.addEventListener("keydown", onKeyDown);
  return () => document.removeEventListener("keydown", onKeyDown);
}, []);

// After: 1行
useHotkey("Mod+K", () => setOpen((prev) => !prev));

Mod は macOS では Meta(Command)、Windows/Linux では Control に自動解決されます。クロスプラットフォーム対応がキー名一つで済みます。

2. テーマ切替 T

useCallback でトグルロジックを抽出し、onClickuseHotkey の両方から呼んでいます。

const switchTheme = useCallback(() => {
  const next = resolvedTheme === "dark" ? "light" : "dark";
  playSound(next === "dark" ? "/audio/switch-on.mp3" : "/audio/switch-off.mp3");
  setTheme(next);
}, [resolvedTheme, setTheme]);

useHotkey("T", switchTheme);

単一キーなので、コマンドパレットの検索欄やその他の入力フィールドでは自動的に無視されます。

3. ブログ記事ナビゲーション ← →

ブログ記事の詳細ページに前後記事への導線が一切なかったのは、ショートカット以前にUXの問題でした。ArrowLeft/ArrowRight のホットキーに加えて、UIとしてもナビゲーションリンクを追加しています。

useHotkey("ArrowLeft", () => {
  if (previousPost) {
    navigate({ to: "/blog/$slug", params: { slug: previousPost.slug } });
  }
});

記事は日付降順でソートしているので、currentIndex + 1 が古い記事(前)、currentIndex - 1 が新しい記事(次)になります。

4. コマンドパレットのショートカットヒント

cmdk のラッパーには CommandShortcut コンポーネントが定義済みでしたが、未使用でした。テーマ切替アイテムの横に T と表示するだけで、ショートカットの発見性が上がります。

<CommandItem onSelect={...}>
  <SunIcon />
  テーマ切替
  <CommandShortcut>T</CommandShortcut>
</CommandItem>

5. ショートカットヘルプダイアログ ?

GitHub、Linear、Notion など開発者向けサービスでは ? でショートカット一覧が表示されます。同じパターンを実装しました。

ここで一つ型の問題がありました。Shift+/ は TypeScript の型エラーになります。@tanstack/hotkeysHotkey 型は、ShiftPunctuationKey/ を含む)の組み合わせを明示的に除外しています。Shift を押すと event.key がレイアウト依存で変わるためです(US配列では /?、他の配列では異なる可能性がある)。

解決策は RawHotkey オブジェクト形式を使うことです。

// 型エラー: Shift+PunctuationKey は Hotkey 型に含まれない
useHotkey("Shift+/", callback);

// OK: RawHotkey オブジェクト形式ならキーを個別に指定できる
useHotkey({ key: "/", shift: true }, callback);

内部的には event.codeSlash)へのフォールバックで物理キーを判定するため、キーボードレイアウトに依存しません。

振り返り

@tanstack/react-hotkeys を選んだ判断は正しかったと思います。API がシンプルで、入力要素の無視や preventDefault が自動化されており、ボイラープレートが大幅に減りました。TanStack エコシステムに統一できる点も保守性に効きます。

一方で型システムの制約(Shift+PunctuationKey の除外)は実装時に初めて気づきました。ライブラリの型定義を読んで RawHotkey 形式に切り替えるまで少し時間がかかっています。型安全は安心感をくれますが、「なぜこの組み合わせが型エラーなのか」を理解するには内部実装の知識が必要でした。

前の記事なぜ今ポートフォリオを作ったのか