Reactで、Ctrl+Kで開くコマンドパレットを作る方法
コマンドパレットは、HTML の dialog 要素を showModal() で開き、中に入力欄とコマンドの一覧を置いて作ります。Esc で閉じる、後ろの画面を操作できなくする、閉じたら元の場所へフォーカスを戻す、といった処理はブラウザが受け持つので、React 側で書くのは「Ctrl+K で開け閉めする」「打った言葉で絞り込む」「↑↓ で選んで Enter で実行する」の3つです。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium にキー入力やクリックを送って確かめました。Firefox と Safari、Mac の実機での ⌘K、実際の日本語入力(IME)、スクリーンリーダーでは試していません。
アプリ全体を StrictMode で囲んでも、開く、「けんさく」で Enter を押して検索欄へ移る、メモの欄から開いて Esc で戻る、のどれも StrictMode で囲まないときと同じ結果になりました。useEffect のリファレンスにあるとおり、StrictMode で effect のセットアップとクリーンアップが余分に実行されるのは、最初のセットアップの前の1回だけです。CommandPalette が最初に描画されたとき(マウントのとき)に渡す open は false なので、余分に実行されても showModal() も close() も呼ばれません。

コマンドは「名前」と「実行する関数」の組にする
パレットに並べるコマンドは、次の形のオブジェクトの配列で渡します。
export type Command = {
id: string;
label: string; // 一覧に出す名前
group: string; // 右に小さく出す分類
keywords?: string[]; // 名前にない言葉でも見つかるようにする
run: () => void;
};
keywords には、名前には出てこないけれど、利用者が打ちそうな言葉を入れます。たとえば「テーマを切り替える」に dark を入れておくと、英語で打っても見つかります。
検証に使った画面では、次のように12個のコマンドを用意しました。make は、実行したら画面の下に「実行:〇〇」と出すための小さな関数です。
const commands = useMemo<Command[]>(() => {
// 実行したら、画面の下に「実行:〇〇」と出す。act を省いたコマンドは、表示だけを変える
const make = (id: string, label: string, group: string, keywords: string[], act = () => {}): Command => ({
id,
label,
group,
keywords,
run: () => {
act();
setLastRun(label);
},
});
return [
make('theme', 'テーマを切り替える', '表示', ['dark', 'ダーク', 'ライト'], () => setDark((d) => !d)),
make('bigger', '文字を大きくする', '表示', ['font', 'zoom'], () => setFontSize((s) => s + 2)),
make('smaller', '文字を小さくする', '表示', ['font', 'zoom'], () => setFontSize((s) => s - 2)),
make('search', '検索欄へ移動', '移動', ['search', 'けんさく'], () => searchRef.current?.focus()),
make('memo', 'メモ欄へ移動', '移動', ['note'], () => memoRef.current?.focus()),
make('top', 'ページの先頭へ移動', '移動', ['top', 'scroll']),
make('new-memo', '新しいメモを作る', '作成', ['new', 'note']),
make('memo-list', 'メモの一覧を開く', 'メモ', ['note', 'list']),
make('memo-export', '書き出す(メモ)', 'メモ', ['export', 'download']),
make('settings', '設定を開く', '全般', ['settings', 'preferences']),
make('shortcuts', 'ショートカットの一覧', '全般', ['keyboard', 'help']),
make('help', 'ヘルプを表示', '全般', ['help', '使い方']),
];
}, []);
Ctrl+K で開け閉めするフック
Ctrl+K は、window の keydown を useEffect で登録して受け取ります。
import { useEffect } from 'react';
// Ctrl+K(Mac では ⌘K)でパレットを開け閉めする。入力欄の中でも反応させる
export function useCommandPaletteShortcut(toggle: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key.toLowerCase() !== 'k' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
if (e.isComposing) return; // 変換中のキーは IME に任せる
e.preventDefault(); // 押しっぱなしの2回目以降も、ブラウザの既定の動きは止める
if (e.repeat) return; // 押しっぱなしでは、開け閉めをくり返さない
toggle();
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggle]);
}
呼び出す側では、開いているかどうかを open という state に持ち、反転させる関数を useCallback で包んで渡します。
const toggle = useCallback(() => setOpen((o) => !o), []);
useCommandPaletteShortcut(toggle);
toggle を useCallback で包むのは、レンダーのたびに別の関数にならないようにするためです。別の関数になると、effect が依存する値が変わったことになり、リスナーの登録と解除がレンダーのたびにくり返されます。
修飾キーは ctrlKey || metaKey で比べています。MDN の metaKey のページによると、Mac のキーボードでは ⌘ Command キーが Meta キーにあたります。検証では、Linux の Chromium で Meta+K を押したときも、Ctrl+K と同じようにパレットが開きました。
「/」のように文字を打つキーのショートカットとは違い、入力欄の中で押されたかどうかは見ていません。コマンドパレットはどこからでも呼び出せるほうが便利なので、入力欄の中でも開けるようにしました。検証でも、メモの欄に文字を打っている途中で Ctrl+K を押すとパレットが開き、パレットの入力欄の中でもう一度押すと閉じました。
preventDefault を呼んで、このキー入力に対するブラウザの既定の動きを止めます。ブラウザ本体のショートカットにどんな動きが割り当てられていて、それが実際に止まるかどうかは、ヘッドレスの Chromium では確かめていません。
押しっぱなしにしたときの keydown は、repeat が true になります。このときは開け閉めをせずに返しますが、preventDefault はその前に呼んでいます。Ctrl を押したまま K の keydown を3回送ったところ、パレットは1回目で開いたままで、3回とも defaultPrevented は true でした。
dialog の showModal で開く
パレットの本体です。open が変わったら、effect の中で showModal() と close() を呼びます。
import { useEffect, useId, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import type { Command } from './commands';
import { filterCommands } from './filterCommands';
type Props = {
open: boolean;
onClose: () => void;
commands: Command[];
};
export function CommandPalette({ open, onClose, commands }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
function run(command: Command) {
// 先に閉じてフォーカスを戻してから、コマンドを実行する
dialogRef.current?.close();
command.run();
}
return (
<dialog
ref={dialogRef}
className="palette"
aria-label="コマンドパレット"
onClose={onClose}
onClick={(e) => {
if (e.target === e.currentTarget) onClose(); // 幕の部分をクリックしたら閉じる
}}
>
{open && <PaletteBody commands={commands} onRun={run} />}
</dialog>
);
}
showModal() で開いた dialog は、MDN の dialog 要素のページにあるとおり、Esc キーで閉じることができ、後ろの画面は操作できなくなります。Esc で閉じたときも dialog に close イベントが届くので、onClose で open を false に戻し、React の state と dialog の状態をそろえます。検証でも、Esc で閉じた後に Ctrl+K を1回押すと、パレットが開きました。onClose で open が false に戻っていたので、Ctrl+K の反転で true になったということです。
MDN によると、showModal() で開くと、dialog の中で最初にフォーカスできる要素にフォーカスが移ります。このパレットでは入力欄が先頭にあるので、autoFocus を付けなくても、開いた直後から文字を打てました。閉じたときは、開く前にフォーカスがあった要素へ戻ります。検証では、「コマンド」ボタンから開いて Esc で閉じるとボタンに、メモの欄から開いて閉じるとメモの欄に戻りました。
幕(::backdrop)の部分をクリックしたときは、クリックの target が dialog 自身になるので、e.target === e.currentTarget のときだけ閉じます。dialog は CSS で padding: 0 にしてあり、中身の部分をクリックしたときは中の要素が target になります。
開くたびに入力を空に戻す
中身の PaletteBody は、open が true のときだけ描画しています。閉じると PaletteBody が取り除かれ、その中の state も消えます。React のドキュメントにある、state の保持とリセットの説明のとおり、React はコンポーネントを取り除くと、その state を破棄します。
検証では、「dark」と打ってから Ctrl+K で閉じ、もう一度 Ctrl+K で開くと、入力欄は空になり、候補は12件すべてに戻りました。前に打った言葉が残っていると、開くたびに消す手間がかかります。
打った言葉で絞り込む
絞り込みは、コマンドの配列と入力欄の文字を受け取って、合うものだけを返す関数にしました。
import type { Command } from './commands';
// 全角・半角と大文字・小文字をそろえ、カタカナをひらがなにする
function normalize(s: string): string {
return s
.normalize('NFKC')
.toLowerCase()
.replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
}
export function filterCommands(commands: Command[], query: string): Command[] {
const q = normalize(query.trim());
if (q === '') return commands;
const hits: { command: Command; rank: number }[] = [];
for (const command of commands) {
const label = normalize(command.label);
const keywords = (command.keywords ?? []).map(normalize);
// 名前の先頭に一致 → 名前の途中に一致 → キーワードに一致、の順に並べる
const rank = label.startsWith(q) ? 0 : label.includes(q) ? 1 : keywords.some((k) => k.includes(q)) ? 2 : -1;
if (rank >= 0) hits.push({ command, rank });
}
// sort は安定なので、同じ順位の中では元の並びが保たれる
return hits.sort((a, b) => a.rank - b.rank).map((h) => h.command);
}
比べる前に、名前と入力の両方を同じ形にそろえます。normalize('NFKC') で全角の英数字を半角にし、toLowerCase() で小文字にします。さらにカタカナの「ァ」〜「ヶ」をひらがなに置き換えて、「めも」と打っても「メモ」が見つかるようにしました。長音の「ー」はカタカナとひらがなで同じ文字なので、そのままで合います。
並べ方は、名前の先頭に一致したもの、名前の途中に一致したもの、キーワードだけに一致したもの、の順です。同じ順位の中では、sort が安定であることを利用して、元の配列の並びを残しています。
いくつかの言葉で試した結果です。
「めも」: open=true modal=true focus=palette-input query="めも" 候補=[メモ欄へ移動/メモの一覧を開く/新しいメモを作る/書き出す(メモ)] 選択=メモ欄へ移動 activedescendant一致=true
「メモ」: open=true modal=true focus=palette-input query="メモ" 候補=[メモ欄へ移動/メモの一覧を開く/新しいメモを作る/書き出す(メモ)] 選択=メモ欄へ移動 activedescendant一致=true
「note」: open=true modal=true focus=palette-input query="note" 候補=[メモ欄へ移動/新しいメモを作る/メモの一覧を開く] 選択=メモ欄へ移動 activedescendant一致=true
「DARK」: open=true modal=true focus=palette-input query="DARK" 候補=[テーマを切り替える] 選択=テーマを切り替える activedescendant一致=true
「てーま」: open=true modal=true focus=palette-input query="てーま" 候補=[テーマを切り替える] 選択=テーマを切り替える activedescendant一致=true
「けんさく」: open=true modal=true focus=palette-input query="けんさく" 候補=[検索欄へ移動] 選択=検索欄へ移動 activedescendant一致=true
「xyz」: open=true modal=true focus=palette-input query="xyz" 候補=[] 選択=null activedescendant一致=true 表示="一致するコマンドはありません"
「めも」と「メモ」は同じ結果になりました。名前が「メモ」で始まる2つが先に並び、途中に「メモ」を含む「新しいメモを作る」と「書き出す(メモ)」が後に続きます。「note」はキーワードだけに一致するので、元の配列の順に並びました。全角の「DARK」も、キーワードの dark に一致しています。
ただし、漢字の読みまでは分かりません。「けんさく」で「検索欄へ移動」が見つかるのは、キーワードに「けんさく」を入れてあるからです。漢字の名前のコマンドで読みでも探せるようにしたいときは、読みをキーワードに足しておきます。
↑↓ で選び、Enter で実行する
入力欄と一覧の部分です。フォーカスは入力欄に置いたまま、いま選んでいる行の番号を active に持ちます。
export function PaletteBody({ commands, onRun }: { commands: Command[]; onRun: (c: Command) => void }) {
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const results = filterCommands(commands, query);
const listId = useId();
const optionId = (i: number) => `${listId}-${i}`;
const listRef = useRef<HTMLUListElement>(null);
// 選んでいる行が一覧の外に出たら、見える位置までスクロールする
useEffect(() => {
listRef.current?.children[active]?.scrollIntoView({ block: 'nearest' });
}, [active, query]);
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.nativeEvent.isComposing) return; // 日本語の変換中のキーは IME に任せる
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); // 入力欄のカーソルを動かさない
if (results.length === 0) return;
const step = e.key === 'ArrowDown' ? 1 : -1;
setActive((i) => (i + step + results.length) % results.length);
} else if (e.key === 'Enter' && results[active]) {
e.preventDefault();
onRun(results[active]);
}
}
return (
<>
<input
className="palette-input"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={results[active] ? optionId(active) : undefined}
placeholder="コマンドを検索"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0); // 絞り込みが変わったら、先頭を選び直す
}}
onKeyDown={handleKeyDown}
/>
<ul id={listId} ref={listRef} role="listbox" aria-label="コマンド" className="palette-list">
{results.map((command, i) => (
<li
key={command.id}
id={optionId(i)}
role="option"
aria-selected={i === active}
onMouseMove={() => setActive(i)}
onMouseDown={(e) => e.preventDefault()} // 入力欄からフォーカスを外さない
onClick={() => onRun(command)}
>
{command.label}
<span className="palette-group">{command.group}</span>
</li>
))}
</ul>
{results.length === 0 && <p className="palette-empty">一致するコマンドはありません</p>}
</>
);
}
選んでいる行は、入力欄の aria-activedescendant に、その行の id を入れて伝えます。MDN の combobox ロールのページによると、フォーカスを入力欄に残したまま、一覧の中のいま選ばれている要素を示すのに使う属性です。検証では、どの操作の後も、aria-activedescendant の値と aria-selected="true" の行の id が一致していました。入力欄に role="combobox"、一覧に role="listbox" を付ける作りは、入力候補を出すコンボボックスと同じです。
↑↓ は端で反対側へ回ります。12件の一覧の先頭で ↑ を押すと最後の「ヘルプを表示」が選ばれ、そこで ↓ を押すと先頭に戻りました。一覧は高さの上限を 240px にしてスクロールさせているので、active が変わるたびに scrollIntoView({ block: 'nearest' }) を呼び、選んだ行が見える位置まで最小限だけスクロールします。検証では、最後の行を選んだときに一覧が 140px スクロールし、行の下端が一覧の下端にそろいました。
打つ言葉が変わったら、active を 0 に戻します。「めも」で ↓ を2回押して「新しいメモを作る」を選んでから1文字消すと、選んでいる行は先頭の「メモ欄へ移動」に戻りました。戻さないと、候補の数が減ったときに、存在しない番号を指したままになります。一致するコマンドがないときは「一致するコマンドはありません」と出し、↓ も Enter も何もしません。
日本語の変換を確定する Enter でコマンドが実行されないように、isComposing が true のキーは無視します。スクリプトから isComposing: true の Enter を送ると、コマンドは実行されませんでした(実際の IME では試していません)。
マウスでは、行の上に載せると onMouseMove でその行が選ばれ、クリックで実行します。onMouseDown では preventDefault を呼び、押した瞬間に入力欄からフォーカスが外れないようにしています。検証でも、行の上でマウスのボタンを押して、まだ離していないときのフォーカスは入力欄のままでした。
先に閉じてから、コマンドを実行する
run では、dialogRef.current?.close() を呼んでから command.run() を呼んでいます。この順番は、フォーカスを動かすコマンドで効いてきます。
HTML の仕様にある dialog を閉じる手順では、モーダルの dialog を閉じると、開く前にフォーカスがあった要素へフォーカスを戻します。close イベントは、その後でタスクとして発生します。つまり、close() を呼んだ時点でフォーカスは元の要素に戻っています。
比べるために、コマンドを先に実行して、閉じるのは onClose で state を変えて effect に任せる版も作りました。
function run(command: Command) {
command.run();
onClose(); // dialog が閉じるのは、この後の effect の中
}
どちらの版でも、「コマンド」ボタンにフォーカスを置いて Ctrl+K で開き、「けんさく」と打って Enter で「検索欄へ移動」を実行しました。出力の focus は、そのときフォーカスのある要素の id です。search は検索欄、opener は「コマンド」ボタンを指します。
先に閉じてから実行する版: open=false modal=false focus=search 「実行:検索欄へ移動」 dark=false
先に実行して、閉じるのは state に任せる版: open=false modal=false focus=opener 「実行:検索欄へ移動」 dark=false
先に閉じる版では、検索欄にフォーカスが移りました。いっぽう、先に実行する版では、コマンドは実行されたのに、フォーカスは「コマンド」ボタンに戻っていました。コマンドで検索欄へ移した後に、effect の中の close() が、開く前の要素へフォーカスを戻したためです。
フォーカスを動かさないコマンドなら、フォーカスは開く前の場所に戻ります。「テーマを切り替える」を Enter で実行すると、パレットが閉じて画面が暗いテーマになり、フォーカスは「コマンド」ボタンに戻りました。close() を直接呼んでも close イベントは発生するので、onClose が呼ばれて open の state も false に戻ります。検証でも、実行した後に Ctrl+K を1回押すと、パレットが開きました。
close() を先に呼べば、フォーカスが元の場所に戻ってからコマンドが動くので、コマンドの中で動かしたフォーカスが上書きされません。





