概要
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 不要