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

Claude CodeでReactコマンドパレットを実装する方法【検索・ARIA・キーボード対応】

Claude CodeでReact製コマンドパレットを実装。検索、キーボード操作、ARIA、useDeferredValue最適化まで実例で解説。

Claude CodeでReactコマンドパレットを実装する方法【検索・ARIA・キーボード対応】

コマンドパレットは「速く動ける入口」を作るUI

コマンドパレットとは、Cmd+KCtrl+K で開き、検索しながら画面移動・作成・設定変更・実行を選べる小さな操作窓です。VS Code、Linear、Slack、Notion のようなプロダクトでよく使われます。マウスでメニューをたどらなくても、ユーザーが「やりたいこと」を直接入力できるため、慣れたユーザーほど作業が速くなります。

ただし、見た目だけを真似ると失敗します。検索できるだけのモーダルでは不十分です。実務で使えるコマンドパレットには、キーボードナビゲーション、フォーカス制御、スクリーンリーダー向けの ARIA、重い検索でも入力が固まらない設計、危険な操作を誤実行しないガードが必要です。ARIA は「支援技術に UI の意味を伝える属性群」、combobox は「入力欄と候補リストを組み合わせた部品」と考えると理解しやすいです。

Claude Code に頼むときも「コマンドパレットを作って」だけでは足りません。Masa がこのサイトの管理画面で試したとき、最初の生成結果は見た目は良かったものの、IME 入力中に Enter が発火し、候補が 0 件のときに filtered[-1] 相当の参照が起き、スクリーンリーダーでは現在選択中の候補が伝わりませんでした。この記事では、その失敗を避けるために、コピーして動かせる React/TypeScript 実装まで落とし込みます。

関連する基礎は キーボードショートカット設計アクセシビリティ実装 も合わせて確認してください。大量の候補を扱う場合は パフォーマンス最適化 の考え方も重要です。外部の一次情報としては React の useDeferredValueReact の useMemoWAI-ARIA Combobox PatternWAI-ARIA Modal Dialog Pattern を参照してください。

実装方針

今回はライブラリに依存しない最小構成で作ります。cmdk や Radix UI を使う選択もありますが、Claude Code にレビューさせる前提なら、まず自分のプロダクトに必要な責務を理解しておくほうが安全です。

要件実装ポイント
起動Cmd+K / Ctrl+K のグローバルショートカット
検索labelcategorydescriptionkeywords を対象にフィルタ
速度useDeferredValue で入力を優先し、useMemo で候補計算をメモ化
キーボード上下、Home、End、Enter、Escape、Tab の扱いを明示
ARIAdialogcomboboxlistboxoptionaria-activedescendant
安全性破壊的操作は confirm や権限チェックをコマンド側に置く

Claude Code には次のように依頼すると、生成物の質が上がります。

React 18+ と TypeScript で、依存ライブラリなしのコマンドパレットを作ってください。Cmd+K/Ctrl+K、検索フィルタ、useDeferredValueuseMemo、ARIA roles、aria-activedescendant、IME 入力中の Enter 抑止、フォーカストラップ、破壊的コマンドの確認例を含めてください。コードは CommandPalette.tsxuseCommandActions.tscommand-palette.css に分け、コピーして動く形にしてください。

最小セットアップ

既存の React アプリに入れる場合、追加パッケージは不要です。新規で検証するだけなら Vite などの React テンプレートを使ってください。

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

コピーして使える CommandPalette.tsx

以下は依存ライブラリなしで動く本体です。useDeferredValue は入力中の体感を優先するために使います。useMemo は検索結果の再計算を必要なときだけに絞るために使います。どちらも「何でも速くする魔法」ではなく、重い候補計算や大量のコマンドがあるときに効く道具です。

import {
  useCallback,
  useDeferredValue,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import "./command-palette.css";

export type Command = {
  id: string;
  label: string;
  category: string;
  description?: string;
  keywords?: string[];
  shortcut?: string;
  disabled?: boolean;
  run: () => void | Promise<void>;
};

type CommandPaletteProps = {
  commands: Command[];
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  placeholder?: string;
  emptyLabel?: string;
};

const normalize = (value: string) => value.trim().toLocaleLowerCase();

function rankCommand(command: Command, terms: string[], fullQuery: string) {
  if (command.disabled) return -1;
  if (terms.length === 0) return 1;

  const label = command.label.toLocaleLowerCase();
  const category = command.category.toLocaleLowerCase();
  const description = command.description?.toLocaleLowerCase() ?? "";
  const keywords = (command.keywords ?? []).join(" ").toLocaleLowerCase();
  const haystack = `${label} ${category} ${description} ${keywords}`;

  if (terms.some((term) => !haystack.includes(term))) return -1;
  if (label === fullQuery) return 100;
  if (label.startsWith(fullQuery)) return 80;
  if (label.includes(fullQuery)) return 60;
  if (category.includes(fullQuery)) return 40;
  return 20;
}

export function CommandPalette({
  commands,
  open: controlledOpen,
  onOpenChange,
  placeholder = "コマンドを検索...",
  emptyLabel = "該当するコマンドがありません",
}: CommandPaletteProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);
  const listboxId = useId();
  const titleId = useId();

  const open = controlledOpen ?? uncontrolledOpen;
  const deferredQuery = useDeferredValue(query);
  const normalizedQuery = normalize(deferredQuery);

  const setOpen = useCallback(
    (nextOpen: boolean) => {
      if (controlledOpen === undefined) {
        setUncontrolledOpen(nextOpen);
      }
      onOpenChange?.(nextOpen);
    },
    [controlledOpen, onOpenChange],
  );

  const visibleCommands = useMemo(() => {
    const terms = normalizedQuery.split(/\s+/).filter(Boolean);

    return commands
      .map((command) => ({
        command,
        score: rankCommand(command, terms, normalizedQuery),
      }))
      .filter((item) => item.score >= 0)
      .sort(
        (a, b) =>
          b.score - a.score || a.command.label.localeCompare(b.command.label),
      )
      .map((item) => item.command);
  }, [commands, normalizedQuery]);

  const activeCommand = visibleCommands[activeIndex];
  const activeOptionId =
    activeCommand === undefined
      ? undefined
      : `${listboxId}-option-${activeIndex}`;

  useEffect(() => {
    const handleGlobalKeyDown = (event: globalThis.KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        setOpen(!open);
      }
    };

    window.addEventListener("keydown", handleGlobalKeyDown);
    return () => window.removeEventListener("keydown", handleGlobalKeyDown);
  }, [open, setOpen]);

  useEffect(() => {
    if (!open) return;

    setQuery("");
    setActiveIndex(0);
    const frameId = window.requestAnimationFrame(() => {
      inputRef.current?.focus();
    });

    return () => window.cancelAnimationFrame(frameId);
  }, [open]);

  useEffect(() => {
    setActiveIndex((currentIndex) => {
      if (visibleCommands.length === 0) return 0;
      return Math.min(currentIndex, visibleCommands.length - 1);
    });
  }, [visibleCommands.length]);

  const runCommand = useCallback(
    (command: Command | undefined) => {
      if (!command || command.disabled) return;

      setOpen(false);
      setQuery("");

      Promise.resolve(command.run()).catch((error) => {
        console.error("Command failed", error);
      });
    },
    [setOpen],
  );

  const moveActiveIndex = useCallback(
    (delta: number) => {
      setActiveIndex((currentIndex) => {
        if (visibleCommands.length === 0) return 0;
        return (
          (currentIndex + delta + visibleCommands.length) %
          visibleCommands.length
        );
      });
    },
    [visibleCommands.length],
  );

  const handleInputKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLInputElement>) => {
      if (event.nativeEvent.isComposing) return;

      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          moveActiveIndex(1);
          break;
        case "ArrowUp":
          event.preventDefault();
          moveActiveIndex(-1);
          break;
        case "Home":
          event.preventDefault();
          setActiveIndex(0);
          break;
        case "End":
          event.preventDefault();
          setActiveIndex(Math.max(visibleCommands.length - 1, 0));
          break;
        case "Enter":
          event.preventDefault();
          runCommand(activeCommand);
          break;
        case "Escape":
          event.preventDefault();
          setOpen(false);
          break;
      }
    },
    [activeCommand, moveActiveIndex, runCommand, setOpen, visibleCommands.length],
  );

  const handleDialogKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      if (event.key !== "Tab") return;

      const focusable = [inputRef.current, closeButtonRef.current].filter(
        Boolean,
      ) as HTMLElement[];
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (!first || !last) return;

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    },
    [],
  );

  if (!open) return null;

  return (
    <div className="cp-backdrop" onMouseDown={() => setOpen(false)}>
      <div
        className="cp-dialog"
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        onMouseDown={(event) => event.stopPropagation()}
        onKeyDown={handleDialogKeyDown}
      >
        <div className="cp-header">
          <h2 id={titleId}>コマンドパレット</h2>
          <button
            ref={closeButtonRef}
            type="button"
            className="cp-close"
            onClick={() => setOpen(false)}
            aria-label="コマンドパレットを閉じる"
          >
            Esc
          </button>
        </div>

        <label className="cp-search-label" htmlFor={`${listboxId}-input`}>
          コマンドを検索
        </label>
        <input
          ref={inputRef}
          id={`${listboxId}-input`}
          className="cp-input"
          value={query}
          onChange={(event) => {
            setQuery(event.target.value);
            setActiveIndex(0);
          }}
          onKeyDown={handleInputKeyDown}
          role="combobox"
          aria-autocomplete="list"
          aria-expanded="true"
          aria-controls={listboxId}
          aria-activedescendant={activeOptionId}
          placeholder={placeholder}
        />

        <ul id={listboxId} className="cp-list" role="listbox">
          {visibleCommands.length === 0 ? (
            <li className="cp-empty">{emptyLabel}</li>
          ) : (
            visibleCommands.map((command, index) => (
              <li
                id={`${listboxId}-option-${index}`}
                key={command.id}
                className="cp-option"
                role="option"
                aria-selected={index === activeIndex}
                data-active={index === activeIndex ? "true" : "false"}
                onMouseMove={() => setActiveIndex(index)}
                onMouseDown={(event) => event.preventDefault()}
                onClick={() => runCommand(command)}
              >
                <span className="cp-option-main">
                  <span className="cp-option-label">{command.label}</span>
                  {command.description ? (
                    <span className="cp-option-description">
                      {command.description}
                    </span>
                  ) : null}
                </span>
                <span className="cp-option-meta">
                  <span className="cp-category">{command.category}</span>
                  {command.shortcut ? (
                    <kbd className="cp-shortcut">{command.shortcut}</kbd>
                  ) : null}
                </span>
              </li>
            ))
          )}
        </ul>

        <div className="cp-footer" aria-hidden="true">
          <kbd>↑</kbd>
          <kbd>↓</kbd>
          <span>移動</span>
          <kbd>Enter</kbd>
          <span>実行</span>
        </div>
      </div>
    </div>
  );
}

コマンド定義は「実行内容」と「検索語」を分ける

コマンド定義は UI から切り離します。こうしておくと、権限、ルーティング、確認ダイアログ、計測イベントを後から足しやすくなります。特に publish、delete、send のような外部副作用を伴う操作は、コマンドパレット本体ではなくコマンド側で確認します。

import type { Command } from "./CommandPalette";

type Navigate = (href: string) => void;

export function createCommandActions(navigate: Navigate): Command[] {
  return [
    {
      id: "new-draft",
      label: "新しい記事を書く",
      category: "コンテンツ",
      description: "空の下書きを作成します",
      keywords: ["create", "post", "article", "draft"],
      shortcut: "N",
      run: () => navigate("/editor/new"),
    },
    {
      id: "media-library",
      label: "画像ライブラリを開く",
      category: "メディア",
      description: "アイキャッチ画像やスクリーンショットを管理します",
      keywords: ["image", "asset", "hero", "upload"],
      shortcut: "G M",
      run: () => navigate("/media"),
    },
    {
      id: "toggle-theme",
      label: "テーマを切り替える",
      category: "設定",
      description: "ライトテーマとダークテーマを切り替えます",
      keywords: ["dark", "light", "appearance"],
      shortcut: "T",
      run: () => {
        const root = document.documentElement;
        root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
      },
    },
    {
      id: "publish-current",
      label: "現在の記事を公開する",
      category: "公開",
      description: "確認後に公開ワークフローへ進みます",
      keywords: ["deploy", "release", "publish"],
      shortcut: "P",
      run: () => {
        const confirmed = window.confirm("現在の記事を公開しますか?");
        if (confirmed) navigate("/publish/current");
      },
    },
  ];
}

CSS でフォーカスと選択状態を明確にする

見た目は派手にするより、選択中の候補、入力欄、閉じる操作が一目で分かることを優先します。アプリのデザイントークンがある場合は、色だけ置き換えてください。

.cp-backdrop {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 14vh 16px 16px;
  background: rgb(15 23 42 / 0.52);
}

.cp-dialog {
  width: min(680px, 100%);
  overflow: hidden;
  border: 1px solid rgb(226 232 240);
  border-radius: 8px;
  background: white;
  box-shadow: 0 24px 80px rgb(15 23 42 / 0.24);
}

.cp-header,
.cp-footer {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  color: rgb(71 85 105);
}

.cp-header {
  justify-content: space-between;
  border-bottom: 1px solid rgb(226 232 240);
}

.cp-header h2 {
  margin: 0;
  font-size: 14px;
  font-weight: 700;
}

.cp-close {
  border: 1px solid rgb(203 213 225);
  border-radius: 6px;
  background: rgb(248 250 252);
  color: rgb(51 65 85);
  cursor: pointer;
  font-size: 12px;
  padding: 4px 8px;
}

.cp-search-label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
}

.cp-input {
  width: 100%;
  border: 0;
  border-bottom: 1px solid rgb(226 232 240);
  font-size: 16px;
  outline: none;
  padding: 14px 16px;
}

.cp-input:focus {
  box-shadow: inset 0 0 0 2px rgb(37 99 235);
}

.cp-list {
  max-height: min(420px, 52vh);
  margin: 0;
  overflow-y: auto;
  padding: 8px;
  list-style: none;
}

.cp-option {
  display: flex;
  min-height: 58px;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  border-radius: 6px;
  cursor: pointer;
  padding: 10px;
}

.cp-option[data-active="true"] {
  background: rgb(239 246 255);
}

.cp-option-main {
  display: grid;
  gap: 3px;
  min-width: 0;
}

.cp-option-label {
  color: rgb(15 23 42);
  font-weight: 650;
}

.cp-option-description {
  color: rgb(100 116 139);
  font-size: 13px;
}

.cp-option-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

.cp-category,
.cp-shortcut,
.cp-footer kbd {
  border: 1px solid rgb(203 213 225);
  border-radius: 6px;
  background: rgb(248 250 252);
  color: rgb(71 85 105);
  font-size: 12px;
  padding: 3px 6px;
}

.cp-empty {
  padding: 32px 12px;
  text-align: center;
  color: rgb(100 116 139);
}

@media (max-width: 640px) {
  .cp-backdrop {
    padding-top: 8vh;
  }

  .cp-option {
    align-items: flex-start;
    flex-direction: column;
  }
}

アプリへの組み込み例

アプリ側では、open を外から制御できるようにしておくと、ヘッダーのボタン、オンボーディング、ヘルプ画面からも開けます。Next.js なら navigaterouter.push に差し替えるだけです。

import { useMemo, useState } from "react";
import { CommandPalette } from "./CommandPalette";
import { createCommandActions } from "./useCommandActions";

export function AppShell() {
  const [paletteOpen, setPaletteOpen] = useState(false);
  const commands = useMemo(
    () =>
      createCommandActions((href) => {
        window.location.href = href;
      }),
    [],
  );

  return (
    <>
      <header className="app-header">
        <button type="button" onClick={() => setPaletteOpen(true)}>
          コマンドを開く <kbd>Ctrl K</kbd>
        </button>
      </header>

      <CommandPalette
        commands={commands}
        open={paletteOpen}
        onOpenChange={setPaletteOpen}
      />
    </>
  );
}

3つ以上の実用ユースケース

1つ目は SaaS 管理画面です。ユーザー検索、請求画面、監査ログ、招待メール送信など、メニューが深くなりがちな機能を横断できます。ただし「ユーザー削除」「プラン変更」「請求書再送信」は即実行せず、確認画面へ遷移させる設計にします。

2つ目はメディアサイトやブログ CMS です。記事作成、画像ライブラリ、カテゴリ編集、公開前チェック、検索インデックス再生成などをコマンド化できます。このサイトでは、記事編集から OGP 画像確認、内部リンクチェックへ移動するショートカットを作ると、毎日の更新作業がかなり短くなりました。

3つ目はドキュメントサイトや開発者向けツールです。API リファレンス、設定例、CLI コマンド、トラブルシュートを横断検索できます。単なる全文検索と違い、「現在のページをコピー」「サンプルを開く」「Issue テンプレートを作る」のようなアクションも候補にできます。

4つ目は社内オペレーションツールです。チケット作成、顧客レコード表示、Slack 通知、CSV エクスポートをコマンド化できます。ここでは権限チェックが重要です。表示できるコマンドだけを渡すのではなく、実行時にもサーバー側で権限を確認してください。

よくある落とし穴

候補の DOM フォーカスを移動してしまうのはよくある失敗です。li に直接フォーカスを当てると、入力欄からフォーカスが外れ、タイプして検索を続けにくくなります。今回の実装では、フォーカスは input に残し、aria-activedescendant で選択中候補を伝えます。

日本語入力中の Enter も見落としやすいです。IME で変換を確定する Enter と、コマンド実行の Enter は別物です。event.nativeEvent.isComposing を確認しないと、変換確定のつもりで公開や削除を実行してしまう危険があります。

検索処理を毎回その場で重くするのも危険です。数十件なら問題になりませんが、数千件の候補や権限フィルタが混ざると入力が詰まります。useDeferredValueuseMemo は、入力の応答性と候補計算の責務を分けるために使います。ただし、サーバー検索が必要な規模なら、クライアントだけで抱え込まず API 側でページングしてください。

最後に、ショートカットの衝突です。ブラウザ、OS、エディタ内のショートカットとぶつかると、ユーザーは混乱します。Cmd+K/Ctrl+K は一般的ですが、入力フォームやリッチエディタ上でどう扱うかはプロダクトごとに決めてください。

実際に試した結果

Masa が記事管理用の小さな管理画面にこの構成を入れたところ、よく使う「新規記事」「画像ライブラリ」「公開前チェック」への移動が 2 クリックから Ctrl+K -> 2〜3文字入力 -> Enter になりました。体感差が大きかったのは検索速度そのものより、キーボードだけで迷わず次の作業へ移れる点です。一方で、公開コマンドを直接実行にした初期案は危険だったため、確認画面への遷移に変えました。コマンドパレットは速さを作る UI ですが、速すぎて事故る導線は設計ミスです。

まとめと次にやること

Claude Code でコマンドパレットを作るなら、見た目よりも「検索、キーボード、ARIA、フォーカス、実行安全性」を最初の要件に入れてください。今回の実装は小さく始められますが、コマンド定義を分離しているため、権限、分析イベント、サーバー検索、最近使ったコマンドの並び替えにも拡張できます。

次は React開発ガイドアクセシビリティ実装 を合わせて読み、プロダクト全体の UI 基盤に組み込んでください。Claude Code に渡す指示テンプレート、レビュー観点、設定例をまとめて使いたい場合は 無料チートシート から受け取れます。チーム導入や設計レビューまでまとめて進めたい場合は 導入相談ページ で相談できます。

#Claude Code #コマンドパレット #UI #キーボード操作 #React
無料

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

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

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

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

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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