レビュー待ち·難易度: 中級·更新: 2026-04-18

React でコマンドパレット風のキーワードフィルタ UI を実装する

キーボードショートカットで開くモーダル内で候補リストをリアルタイムフィルタし、キーボードナビゲーションで選択するコマンドパレット UI の実装例。

nextjssearch-filterui-component

対応バージョン

nextjs 15react 19

前提環境

React の useState・useEffect・useRef とキーボードイベントの基本を理解していること

概要

Cmd+K で開くモーダル内でリストをリアルタイムフィルタし、↑↓ キーで候補を移動、Enter で選択するコマンドパレット UI。サーバーへのリクエストなしにクライアント側のデータを即時フィルタするパターン。

インストール

# 追加インストールは不要

実装

コマンドパレットコンポーネント

// components/CommandPalette.tsx
"use client";

import { useEffect, useRef, useState } from "react";

type Command = {
  id: string;
  label: string;
  description?: string;
  action: () => void;
};

type Props = {
  commands: Command[];
};

export function CommandPalette({ commands }: Props) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  // Cmd+K / Ctrl+K でパレットを開く
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if ((e.metaKey || e.ctrlKey) && e.key === "k") {
        e.preventDefault();
        setOpen((prev) => !prev);
      }
      if (e.key === "Escape") setOpen(false);
    }
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);

  // パレットが開いたら入力欄にフォーカス
  useEffect(() => {
    if (open) {
      setQuery("");
      setActiveIndex(0);
      inputRef.current?.focus();
    }
  }, [open]);

  const filtered = commands.filter(
    (cmd) =>
      cmd.label.toLowerCase().includes(query.toLowerCase()) ||
      cmd.description?.toLowerCase().includes(query.toLowerCase())
  );

  // フィルタ結果が変わったら activeIndex をリセット
  useEffect(() => {
    setActiveIndex(0);
  }, [query]);

  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActiveIndex((i) => Math.max(i - 1, 0));
    } else if (e.key === "Enter") {
      if (filtered[activeIndex]) {
        filtered[activeIndex].action();
        setOpen(false);
      }
    }
  }

  // activeItem が見切れたらスクロール
  useEffect(() => {
    const list = listRef.current;
    const item = list?.children[activeIndex] as HTMLElement | undefined;
    item?.scrollIntoView({ block: "nearest" });
  }, [activeIndex]);

  if (!open) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24"
      onClick={() => setOpen(false)}
    >
      <div
        className="w-full max-w-xl overflow-hidden rounded-xl border bg-white shadow-2xl"
        onClick={(e) => e.stopPropagation()}
      >
        {/* 検索入力 */}
        <div className="border-b px-4 py-3">
          <input
            ref={inputRef}
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="コマンドを検索..."
            className="w-full text-sm outline-none placeholder:text-gray-400"
          />
        </div>

        {/* 候補リスト */}
        <ul ref={listRef} className="max-h-72 overflow-y-auto py-2">
          {filtered.length === 0 ? (
            <li className="px-4 py-3 text-sm text-gray-400">該当なし</li>
          ) : (
            filtered.map((cmd, i) => (
              <li
                key={cmd.id}
                className={`flex cursor-pointer flex-col px-4 py-2 ${
                  i === activeIndex ? "bg-blue-50" : "hover:bg-gray-50"
                }`}
                onMouseEnter={() => setActiveIndex(i)}
                onClick={() => {
                  cmd.action();
                  setOpen(false);
                }}
              >
                <span className="text-sm font-medium text-gray-800">
                  {cmd.label}
                </span>
                {cmd.description && (
                  <span className="text-xs text-gray-400">{cmd.description}</span>
                )}
              </li>
            ))
          )}
        </ul>

        <div className="border-t px-4 py-2 text-xs text-gray-400">
          ↑↓ で移動 · Enter で選択 · Esc で閉じる
        </div>
      </div>
    </div>
  );
}

ページでの使用例

// app/page.tsx
"use client";

import { useState } from "react";
import { CommandPalette } from "@/components/CommandPalette";

export default function Page() {
  const [result, setResult] = useState<string | null>(null);

  const commands = [
    {
      id: "new-file",
      label: "新規ファイル作成",
      description: "空のファイルを作成します",
      action: () => setResult("新規ファイルを作成しました"),
    },
    {
      id: "open-settings",
      label: "設定を開く",
      description: "アプリの設定画面を開きます",
      action: () => setResult("設定を開きました"),
    },
    {
      id: "search",
      label: "ドキュメント検索",
      description: "全体からキーワードで検索します",
      action: () => setResult("検索モードを起動しました"),
    },
    {
      id: "logout",
      label: "ログアウト",
      action: () => setResult("ログアウトしました"),
    },
  ];

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-8">
      <p className="mb-4 text-sm text-gray-500">
        <kbd className="rounded border bg-gray-100 px-2 py-0.5 text-xs">⌘K</kbd>{" "}
        / <kbd className="rounded border bg-gray-100 px-2 py-0.5 text-xs">Ctrl+K</kbd>{" "}
        でパレットを開く
      </p>

      {result && (
        <p className="rounded bg-green-50 px-4 py-2 text-sm text-green-700">
          {result}
        </p>
      )}

      <CommandPalette commands={commands} />
    </main>
  );
}

ポイント

  • window.addEventListener("keydown", ...) でグローバルショートカットを登録する。useEffect の cleanup で必ず removeEventListener を呼ぶ
  • フィルタは includes によるクライアント側リアルタイム検索。サーバーリクエストなしで即時応答する。データが大量な場合は useMemo でフィルタ結果をメモ化する
  • ↑↓ キーで activeIndex を変化させ、scrollIntoView({ block: "nearest" }) でアクティブアイテムを可視範囲に収める
  • モーダルの背景クリックで閉じる際、内側のクリックが伝播しないよう e.stopPropagation() を使う
  • react-debounce-search との違い: debounce search は入力後一定時間待ってから API を叩く非同期型。このパターンはクライアント側のデータを即時フィルタするため debounce 不要

注意点

react-debounce-search はデバウンスを使った入力検索 UI。nextjs-api-search は API へのリクエスト型検索。react-multi-filter はチェックボックスフィルタ。これはモーダル内でクライアント側リストをリアルタイムフィルタし、↑↓キーで選択するコマンドパレット型 UI に特化。

関連サンプル