© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjssearch-filter

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

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

難易度: 中級·更新: 2026-04-18

対応バージョン

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 に特化。

関連サンプル

同じテーマや技術スタックを使った実装例

  • React で検索入力欄にクリアボタンを追加する

    検索フィールドに入力内容を一発でクリアする × ボタンを追加し、入力中だけ表示する制御と URL クエリへの反映パターンを示す例。

  • React で検索キーワードに応じた 0 件 EmptyState を表示する

    検索語の有無・フィルタ条件の有無によって異なる 0 件メッセージを出し分け、クリアボタンや別キーワード提案など検索導線につながる EmptyState を実装する例。

  • React で検索結果が 0 件のときに関連キーワードを提案する

    検索結果が 0 件のとき、入力キーワードに近い候補を提案して検索のやり直しを促す UX パターン。部分一致による候補抽出とワンクリック再検索の実装例。

  • React で検索入力欄にキーボードショートカットでフォーカスする

    / や Ctrl+K などのキーボードショートカットで検索入力欄へフォーカスを移動するパターン。useEffect と keydown イベントで実装し、入力中の誤作動を防ぐ制御も示す。

  • React で検索キーワードにマッチした文字列をハイライト表示する

    入力された検索語と一致するテキスト部分を分割して <mark> タグや span で強調表示するコンポーネントの実装例。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る