Use Cases (更新: 2026/6/7)

cmdkでCmd+Kコマンドパレットを自作する(検索・キー操作・最近使った項目)

cmdkでReact製コマンドパレットを実装。Cmd+K起動、ファジー検索、キーボード移動、アクション登録、最近使った項目、アクセシビリティまでコピペで動くコードで紹介。

cmdkでCmd+Kコマンドパレットを自作する(検索・キー操作・最近使った項目)

「あの設定画面、どこだっけ」。マウスを握ったまま、3階層下のメニューを探して30秒。1日に何回もこれをやっていると気づいて、僕はうんざりしました。

VS CodeもLinearもSlackも、Cmd+K を押せば一発で目的地に飛べます。あの操作窓——コマンドパレットを、自分のReact管理画面にも付けたい。そう思って最初はゼロから書いたんですが、ファジー検索と矢印キーの折り返しとARIAを全部自前でやったら、地味なバグが延々と出てきました。

そこで cmdk というライブラリに乗り換えたら、半日かかっていた部分が小一時間で片付きました。今日はその実装手順を、コピペで動くコードと一緒に共有します。

この記事の要点

  • cmdk を使うと、ファジー検索・矢印キー移動・候補の並べ替え・ARIA属性が最初から付いてくる。自分で書くのは「どんなアクションを並べるか」だけ。
  • Command.Dialog を使えば Cmd+K で開くモーダル型パレットが数十行で作れる。フォーカス管理も内蔵。
  • 「最近使った項目」は localStorage に id を積むだけ。並び替えに使うと体感がぐっと上がる。
  • 公開・削除みたいな危ない操作は、パレット本体ではなく onSelect の中で確認する。速すぎる導線は事故る。
  • キーボードショートカット全般の設計は別記事に切り出した。Cmd+K以外のショートカット設計 も合わせてどうぞ。

コマンドパレットって、要するに何?

コマンドパレットは、Cmd+K(WindowsやLinuxでは Ctrl+K)で開く小さな検索窓です。文字を打つと、画面移動・新規作成・設定変更・各種実行が候補として絞り込まれ、Enterで実行できます。

イメージは、家の電気のリモコンです。壁のスイッチを一つずつ探さなくても、手元から「リビング」「寝室」と指定して直接つけられる。慣れたユーザーほど、メニューをたどるより速く動けます。

ポイントは、ただの検索ボックスではないことです。実務で使えるパレットには、最低でもこれだけ要ります。

  • キーボードだけで上下移動して、Enterで実行できる
  • 入力中も候補がもたつかない(重い検索でも固まらない)
  • スクリーンリーダーに「いま何件中の何番目を選んでいるか」が伝わる
  • 公開・削除のような危険な操作を、うっかり実行しない

この「地味な周辺」を全部自前で書くと、あとで紹介する僕みたいに転びます。cmdk はそこを肩代わりしてくれるライブラリです。

なぜ自前実装ではなく cmdk なのか

最初、僕は依存を増やしたくなくて、ゼロから書きました。useDeferredValue でファジー検索を組んで、矢印キーの折り返しを % 演算で処理して、aria-activedescendant を手で更新して……。動くには動いた。でも、候補0件のときに配列の範囲外を参照したり、日本語変換中のEnterで暴発したり、地雷が次々出てきたんです。

cmdk は、まさにこの面倒な部分のために作られたライブラリです。Linearのチームが公開しているOSSで、Linear本体のパレットと同じ系譜の使い心地が手に入ります。自前実装と比べると、こう違います。

項目自前実装cmdk
ファジー検索自分でスコアリングを書く標準装備(filterで差し替え可)
矢印キー移動・折り返しキーイベントを全部ハンドリングloop を付けるだけ
候補の並べ替えスコア順にソートを実装マッチ度で自動ソート
ARIA / フォーカスrole や id を手で管理内部で付与(VoiceOver検証済み)
書く量数百行アクション定義が主役

「ライブラリに頼ると中身が分からなくなる」という不安はもっともです。でも、コマンドパレットは枯れた部品で、車輪の再発明が一番割に合わない領域でした。僕は中身を理解する勉強として一度は自前で書きましたが、本番には cmdk を使っています。

まず動かす:最小セットアップ

既存のReactアプリ(React 18以上)にそのまま入れられます。新規で試すなら、Viteのテンプレートが手軽です。

npm create vite@latest palette-demo -- --template react-ts
cd palette-demo
npm install
npm install cmdk
npm run dev

これで準備完了です。追加で必要なのは cmdk ひとつだけ。Radix UIのDialogに依存していますが、cmdk をインストールすれば一緒に入ります。

コピペで動く:Cmd+Kで開くコマンドパレット

ここが本題です。Command.Dialog を使うと、Cmd+K で開くモーダル型パレットが一気に書けます。下のコードをそのまま CommandPalette.tsx に貼れば動きます。日本語コメントを多めに入れたので、どこが何をしているか追えるはずです。

import { Command } from "cmdk";
import { useEffect, useState } from "react";

// パレットに並べる1件分のアクション。run() に実際の処理を書く
type Action = {
  id: string;
  label: string;
  group: string;
  keywords?: string[]; // ラベルに無い語でも引っかけたいとき用
  run: () => void;
};

// 表示するアクション一覧。navigate は画面遷移の関数を渡す想定
function buildActions(navigate: (href: string) => void): Action[] {
  return [
    {
      id: "new-draft",
      label: "新しい記事を書く",
      group: "コンテンツ",
      keywords: ["create", "post", "draft", "しんき"],
      run: () => navigate("/editor/new"),
    },
    {
      id: "media",
      label: "画像ライブラリを開く",
      group: "メディア",
      keywords: ["image", "asset", "hero", "がぞう"],
      run: () => navigate("/media"),
    },
    {
      id: "toggle-theme",
      label: "テーマを切り替える",
      group: "設定",
      keywords: ["dark", "light", "theme"],
      run: () => {
        const root = document.documentElement;
        root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
      },
    },
    {
      id: "publish",
      label: "現在の記事を公開する",
      group: "公開",
      keywords: ["deploy", "release", "こうかい"],
      // 危ない操作は run() の中で必ず確認する(パレット本体ではやらない)
      run: () => {
        if (window.confirm("現在の記事を公開しますか?")) {
          navigate("/publish/current");
        }
      },
    },
  ];
}

export function CommandPalette({
  navigate,
}: {
  navigate: (href: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const actions = buildActions(navigate);

  // Cmd+K / Ctrl+K で開閉。ブラウザ既定のショートカットは preventDefault で止める
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((prev) => !prev);
      }
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, []);

  // アクションを実行したら閉じる
  const handleSelect = (action: Action) => {
    setOpen(false);
    action.run();
  };

  // group ごとにまとめてセクション表示する
  const groups = [...new Set(actions.map((a) => a.group))];

  return (
    <Command.Dialog
      open={open}
      onOpenChange={setOpen}
      label="コマンドパレット"
      loop // 末尾で下キーを押すと先頭へ折り返す
    >
      <Command.Input placeholder="コマンドを検索..." />
      <Command.List>
        <Command.Empty>該当するコマンドがありません</Command.Empty>
        {groups.map((group) => (
          <Command.Group key={group} heading={group}>
            {actions
              .filter((a) => a.group === group)
              .map((action) => (
                <Command.Item
                  key={action.id}
                  // value はラベル+キーワードを検索対象にするため連結
                  value={`${action.label} ${(action.keywords ?? []).join(" ")}`}
                  onSelect={() => handleSelect(action)}
                >
                  {action.label}
                </Command.Item>
              ))}
          </Command.Group>
        ))}
      </Command.List>
    </Command.Dialog>
  );
}

これだけで、Cmd+K 起動・ファジー検索・矢印キー移動(末尾で先頭に折り返し)・グループ表示・候補0件メッセージが全部動きます。自前実装で数百行かけた部分が、ほぼ宣言だけで済んでいるのが分かると思います。

アプリ側からは、こう差し込むだけです。

import { CommandPalette } from "./CommandPalette";

export function App() {
  return (
    <>
      {/* 既存の画面 */}
      <CommandPalette navigate={(href) => (window.location.href = href)} />
    </>
  );
}

Next.jsなら navigaterouter.push を渡せばそのまま使えます。スタイルは cmdk が付ける [cmdk-...] 属性に対してCSSを当てるか、公式のスタイル例をベースに整えてください。

「最近使った項目」を足して体感を上げる

パレットを毎日使い始めると、結局よく使うコマンドは数個に固定されてきます。そこで「最近使った項目」を上に出すと、入力すらほぼ要らなくなります。

実装は驚くほど単純で、実行したアクションのidを localStorage に積んで、表示時に並び替えに使うだけです。

import { useState } from "react";

const STORAGE_KEY = "palette:recent";

// localStorage から最近使ったidの配列を読む
function loadRecent(): string[] {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
  } catch {
    return [];
  }
}

export function useRecentActions() {
  const [recent, setRecent] = useState<string[]>(loadRecent);

  // 実行したidを先頭に積む。重複は除き、最大5件まで保持
  const pushRecent = (id: string) => {
    setRecent((prev) => {
      const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
      localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
      return next;
    });
  };

  return { recent, pushRecent };
}

あとは handleSelect の中で pushRecent(action.id) を呼び、表示時に recent に含まれるidを先頭の「最近使った」グループへ寄せるだけです。cmdk は検索中はグループの並びをマッチ度で再構成しますが、検索語が空のとき(=開いた直後)にこの並びが効くので、ちょうど狙いどおりになります。

僕の管理画面では、これを入れてから「新規記事」へ飛ぶのが Cmd+K → Enter、つまり実質2打鍵になりました。検索すらしない日が増えています。

こんな場面で効く(3つ)

1. SaaSの管理画面 ユーザー検索、請求画面、監査ログ、招待メール送信。メニューが深くなりがちな機能を横断できます。ただし「ユーザー削除」「プラン変更」は即実行にせず、onSelect から確認画面へ遷移させます。表示できるコマンドを絞るだけでなく、実行時にもサーバー側で権限を必ず確認してください。

2. ブログ・CMS 記事作成、画像ライブラリ、カテゴリ編集、公開前チェック。このサイトでは、記事編集からOGP画像確認、内部リンクチェックへ飛ぶアクションを登録したら、毎日の更新作業がかなり短くなりました。

3. 開発者向けツール・ドキュメント APIリファレンス、設定例、CLIコマンドの横断検索。全文検索と違うのは、「現在のページをコピー」「サンプルを開く」「Issueテンプレートを作る」のような“実行”も候補にできる点です。

僕がやらかした失敗3つ

正直に書きます。最初のパレットは事故だらけでした。

ひとつ目は、公開コマンドを直接実行にしたことCmd+K から3文字打ってEnter、の勢いで記事を本番公開してしまいました。速いUIほど、勢いで危ない操作まで通ってしまう。今は破壊的な操作を全部 onSelect 内の confirm か確認画面に通しています。

ふたつ目は、日本語入力中のEnterで暴発させたこと。自前実装のころ、IMEで変換を確定するEnterと実行のEnterを区別せず、変換確定のつもりで削除を実行しかけました。cmdk はこのIME問題を内部で面倒みてくれるので、乗り換えてから一度も再発していません。

みっつ目は、キーワードを英語だけにしたことlabel が日本語なのに keywords を英語だけにしていたら、「がぞう」と打っても画像ライブラリが出ない。value にラベルとキーワードを連結し、ローマ字読みも keywords に足したら、打ち方を選ばずヒットするようになりました。

アクセシビリティで外せない点

cmdk はARIA属性とフォーカス管理を内部で付けてくれます(公式がVoiceOverとChrome DevToolsで検証済み)。ただ、丸投げにせず最低限ここは自分で確認してください。

  • Command.Dialoglabel を必ず渡す。スクリーンリーダーが「何のダイアログか」を読み上げる元になります。
  • 選択中の候補が視覚的に分かる色を付ける。[cmdk-item][aria-selected="true"] にCSSを当てます。
  • 候補へ直接フォーカスを移さない。フォーカスは入力欄に残し、選択状態は属性で伝えるのが定石です(cmdk はこの方式です)。

ARIAの考え方そのものは奥が深いので、支援技術に正しく意味を伝える実装 に別途まとめてあります。combobox/listboxの背景を知りたい人は、一次情報の WAI-ARIA Combobox Pattern も参照してください。

Claude Codeに頼むときのコツ

Claude Codeに「コマンドパレット作って」とだけ言うと、たいてい見た目重視の浅い実装が返ってきます。要件を具体的に渡すと、生成物の質が一段上がります。

cmdk(React 18+, TypeScript)でコマンドパレットを作って。Command.Dialog で Cmd+K / Ctrl+K 起動、loop で折り返し、group 表示、Command.Empty、最近使った項目を localStorage で並び替え、破壊的コマンドは onSelect 内で confirm、を含めて。ファイルは CommandPalette.tsx と useRecentActions.ts に分けて、コピペで動く形にして。

大量の候補をサーバー検索する規模になったら、shouldFilter={false} にしてAPI側でページングする方針に切り替えます。クライアントで数千件を抱え込むと入力が詰まるので、そのときは 大量データでも詰まらせない設計 の考え方が効いてきます。

よくある質問

Q. cmdk と自前実装、どちらを選ぶべき? 特別な理由がなければ cmdk です。ファジー検索・キー操作・ARIAは枯れた部品で、自前で書くと地味なバグの温床になります。中身を勉強したいなら一度自作するのは良い経験ですが、本番は乗せ替えをおすすめします。

Q. ファジー検索のロジックを差し替えられる? できます。Commandfilter={(value, search, keywords) => 数値} を渡すと、独自のスコアリングに置き換わります。返り値が0なら非表示、大きいほど上位です。日本語の表記ゆれに合わせたいときに便利です。

Q. 矢印キーで末尾から先頭に戻したい。 Command(または Command.Dialog)に loop を付けるだけです。リストの端で方向キーを押すと反対端へ折り返します。

Q. Next.js のApp Routerでも使える? 使えます。cmdk はクライアント部品なので、ファイル先頭に "use client" を付けてください。navigate には useRouter()router.push を渡します。

Q. 検索対象に説明文やカテゴリも入れたい。 Command.Itemvalue に検索したい文字列をまとめて入れるか、keywords プロパティに配列で渡します。keywords はマッチ判定に加味され、表示には出ません。

実際に試した結果

このサイトの記事管理画面に cmdk 版を入れてから、よく使う「新規記事」「画像ライブラリ」「公開前チェック」への移動が、メニューを2〜3クリックたどる作業から Cmd+K → 数文字 → Enter になりました。さらに「最近使った項目」を足したら、よく使うものはほぼ無入力で選べます。

体感差が一番大きかったのは、検索速度そのものより、手をキーボードから離さずに次の作業へ移れることでした。一方で、公開コマンドを直接実行にした初期案は本当に危なかったので、確認画面への遷移に変えました。コマンドパレットは速さを作るUIですが、速すぎて事故る導線は設計ミスです。

まずは上のコードを貼って、自分のアプリに3つだけアクションを登録してみてください。Cmd+K から目的地に飛べる快感を一度味わうと、もうメニューを手でたどる気にはなれなくなります。次の一歩として Cmd+K以外のショートカット設計 を読むと、パレットと併用するキー体系まで整います。指示テンプレートやレビュー観点をまとめて使いたい人は 教材一覧 からどうぞ。

#cmdk #コマンドパレット #Cmd+K #React #Claude Code
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。