implementing-command-palettes
ReactでCmd+Kのようなコマンドパレットを実装する際に、キーボード操作、選択項目の表示維持、ショートカットによる絞り込み、不要な再レンダリング防止などをスムーズに行えるように支援するSkill。
📜 元の英語説明(参考)
Use when building Cmd+K command palettes in React - covers keyboard navigation with arrow keys, keeping selected items in view with scrollIntoView, filtering with shortcut matching, and preventing infinite re-renders from reference instability
🇯🇵 日本人クリエイター向け解説
ReactでCmd+Kのようなコマンドパレットを実装する際に、キーボード操作、選択項目の表示維持、ショートカットによる絞り込み、不要な再レンダリング防止などをスムーズに行えるように支援するSkill。
※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。
下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o implementing-command-palettes.zip https://jpskill.com/download/17079.zip && unzip -o implementing-command-palettes.zip && rm implementing-command-palettes.zip
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/17079.zip -OutFile "$d\implementing-command-palettes.zip"; Expand-Archive "$d\implementing-command-palettes.zip" -DestinationPath $d -Force; ri "$d\implementing-command-palettes.zip"
完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。
💾 手動でダウンロードしたい(コマンドが難しい人向け)
- 1. 下の青いボタンを押して
implementing-command-palettes.zipをダウンロード - 2. ZIPファイルをダブルクリックで解凍 →
implementing-command-palettesフォルダができる - 3. そのフォルダを
C:\Users\あなたの名前\.claude\skills\(Win)または~/.claude/skills/(Mac)へ移動 - 4. Claude Code を再起動
⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。
🎯 このSkillでできること
下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。
📦 インストール方法 (3ステップ)
- 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
- 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
- 3. 展開してできたフォルダを、ホームフォルダの
.claude/skills/に置く- · macOS / Linux:
~/.claude/skills/ - · Windows:
%USERPROFILE%\.claude\skills\
- · macOS / Linux:
Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。
詳しい使い方ガイドを見る →- 最終更新
- 2026-05-18
- 取得日時
- 2026-05-18
- 同梱ファイル
- 1
📖 Skill本文(日本語訳)
※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。
コマンドパレットの実装
概要
コマンドパレット(Cmd+K / Ctrl+K)は、正確なキーボードナビゲーション、スクロール動作、および再レンダリングループを避けるための安定した参照を必要とします。このスキルでは、コマンドパレットを応答性が高く感じさせるメカニカルなパターンについて説明します。
使用する場面
- ReactでCmd+Kコマンドパレットを構築する
- 視覚的な選択による矢印キーナビゲーションを実装する
- キーボードナビゲーション中に選択されたアイテムを表示したままにする
- ラベルテキストとキーボードショートカットでコマンドをフィルタリングする
- コマンドの更新時に無限の再レンダリングが発生する
クイックリファレンス
| 機能 | 実装 |
|---|---|
| 矢印ナビゲーション | selectedIndex を追跡し、Math.min/max でクランプする |
| 表示状態を維持 | scrollIntoView({ block: 'nearest', behavior: 'smooth' }) |
| ショートカットマッチング | ショートカットからスペースを削除し、クエリと照合する |
| 安定したアイコン | コンポーネントの外でアイコン要素を定義する |
| 安定したハンドラ | 無効状態のための useCallback + noop 定数 |
キーボードナビゲーション
重要: 条件付きレンダリングのためのラッパーパターン
これはバグの最も一般的な原因です。 キーボードエフェクトは、パレットが開いている場合にのみ実行する必要があります。ラッパーコンポーネントを使用してください。
// ラッパーは、エフェクトが開いている場合にのみ実行されることを保証します
export function CommandPalette(props: CommandPaletteProps) {
if (!props.isOpen) return null;
return <CommandPaletteContent {...props} />;
}
// コンテンツコンポーネント - エフェクトはマウント/アンマウント時に実行されます
function CommandPaletteContent({ onClose, ... }: CommandPaletteProps) {
// ここでのエフェクトは、パレットが表示されている場合にのみ実行されます
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { ... };
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [deps]);
return <div>...</div>;
}
これが重要な理由:
useEffectフックの後にif (!isOpen) return nullを配置すると、閉じられたときでもエフェクトが実行されます- これにより、パレットが非表示の場合でもキーボードリスナーが登録されます
- ラッパーパターンは、コンポーネントが実際にレンダリングされる場合にのみエフェクトが実行されることを保証します
入力フォーカス + ウィンドウリスナーパターン
入力はフォーカスされている必要があり(入力が機能するため)、キーボードナビゲーションは window.addEventListener を使用する必要があります。これは、次の理由で機能します。
- ウィンドウリスナーは、すべてのキーのkeydownイベントを受信します
- 矢印キーはテキストを入力に挿入しないため、
e.preventDefault()はページのスクロールを停止するだけです - 通常の文字キーは、入力のために到達します
// autoFocus付きの入力 - setTimeoutフォーカスではありません
<input
autoFocus
type="text"
value={query}
onChange={e => {
setQuery(e.target.value);
setSelectedIndex(0); // クエリが変更されたときに最初のアイテムにリセットします
}}
/>
インデックス管理
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
// 最後のアイテムにクランプします
setSelectedIndex(prev => Math.min(prev + 1, filteredItems.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
// 最初のアイテムにクランプします
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (filteredItems[selectedIndex]) {
executeCommand(filteredItems[selectedIndex]);
close();
}
break;
case 'Escape':
e.preventDefault();
close();
break;
}
};
// キャプチャフェーズは不要です - シンプルなウィンドウリスナーはフォーカスされた入力で動作します
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, filteredItems, selectedIndex, close]);
重要なパターン:
e.preventDefault()は、矢印キーがページをスクロールするのを停止しますMath.min/maxは、インデックスが範囲外になるのを防ぎます- エフェクトは
filteredItemsに依存するため、フィルタが変更されるとナビゲーションが更新されます - 入力には
autoFocusを使用し、setTimeout(() => ref.current?.focus(), 0)は使用しません
選択されたアイテムを表示状態に保つ
Refs配列の使用
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
// スクロールエフェクト - 選択が変更されたときに実行されます
useEffect(() => {
const selectedItem = itemRefs.current[selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({
block: 'nearest', // 最小限のスクロール - 必要な場合にのみスクロールします
behavior: 'smooth' // スムーズなアニメーション
});
}
}, [selectedIndex]);
// レンダリングでrefsを割り当てます
{filteredItems.map((item, index) => (
<button
key={index}
ref={el => { itemRefs.current[index] = el; }}
className={index === selectedIndex ? 'bg-blue-100' : ''}
>
{item.label}
</button>
))}
代替案: 選択されたアイテムの単一のRef
const selectedItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen && selectedItemRef.current) {
selectedItemRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}, [isOpen, selectedIndex]);
// 選択されたアイテムにのみrefを割り当てます
<button
ref={index === selectedIndex ? selectedItemRef : null}
>
なぜ block: 'nearest' なのか?
'nearest'は、要素が可視領域の外にある場合にのみスクロールします'center'は、アイテムがすでに表示されている場合でもスクロールし、ぎくしゃくした動きを引き起こします'start'または'end'は、常に上/下に揃えます
ショートカットマッチングによるフィルタリング
const filteredCommands = commands.filter(command => {
const q = query.toLowerCase().trim();
if (!q) return true;
// 標準ラベルマッチング
if (command.label.toLowerCase().includes(q)) return true;
// ショートカットマッチング: "gd" は "g d" に一致し、"gb" は "g b" に一致します
if (command.shortcut) {
const shortcutNoSpaces = command.shortcut.toLowerCase().replace(/\s+/g, '');
if (shortcutNoSpaces.startsWith(q) || shortcutNoSpaces.includes(q)) {
return true;
}
}
// 番号付きアイテム(PR、issue)の場合、
(原文がここで切り詰められています) 📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開
Implementing Command Palettes
Overview
Command palettes (Cmd+K / Ctrl+K) need precise keyboard navigation, scroll behavior, and stable references to avoid re-render loops. This skill covers the mechanical patterns that make command palettes feel responsive.
When to Use
- Building a Cmd+K command palette in React
- Implementing arrow key navigation with visual selection
- Keeping selected items visible during keyboard navigation
- Filtering commands by label text AND keyboard shortcuts
- Experiencing infinite re-renders when commands update
Quick Reference
| Feature | Implementation |
|---|---|
| Arrow navigation | Track selectedIndex, clamp with Math.min/max |
| Keep in view | scrollIntoView({ block: 'nearest', behavior: 'smooth' }) |
| Shortcut matching | Strip spaces from shortcuts, match against query |
| Stable icons | Define icon elements outside component |
| Stable handlers | useCallback + noop constant for disabled states |
Keyboard Navigation
Critical: Wrapper Pattern for Conditional Rendering
This is the most common source of bugs. The keyboard effect must ONLY run when the palette is open. Use a wrapper component:
// Wrapper ensures effects only run when open
export function CommandPalette(props: CommandPaletteProps) {
if (!props.isOpen) return null;
return <CommandPaletteContent {...props} />;
}
// Content component - effects run on mount/unmount
function CommandPaletteContent({ onClose, ... }: CommandPaletteProps) {
// Effects here only run when palette is visible
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { ... };
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [deps]);
return <div>...</div>;
}
Why this matters:
- If you put
if (!isOpen) return nullAFTER useEffect hooks, the effects still run when closed - This causes keyboard listeners to be registered even when palette is invisible
- The wrapper pattern ensures effects only run when the component actually renders
Input Focus + Window Listener Pattern
The input MUST be focused (for typing to work), and keyboard navigation MUST use window.addEventListener. This works because:
- The window listener receives keydown events for ALL keys
- Arrow keys don't insert text into inputs, so
e.preventDefault()just stops page scrolling - Regular character keys still reach the input for typing
// Input with autoFocus - NOT setTimeout focus
<input
autoFocus
type="text"
value={query}
onChange={e => {
setQuery(e.target.value);
setSelectedIndex(0); // Reset to first item when query changes
}}
/>
Index Management
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
// Clamp to last item
setSelectedIndex(prev => Math.min(prev + 1, filteredItems.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
// Clamp to first item
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (filteredItems[selectedIndex]) {
executeCommand(filteredItems[selectedIndex]);
close();
}
break;
case 'Escape':
e.preventDefault();
close();
break;
}
};
// NO capture phase needed - simple window listener works with focused input
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, filteredItems, selectedIndex, close]);
Key patterns:
e.preventDefault()stops arrow keys from scrolling the pageMath.min/maxprevents index going out of bounds- Effect depends on
filteredItemsso navigation updates when filter changes - Use
autoFocuson input, NOTsetTimeout(() => ref.current?.focus(), 0)
Keeping Selected Item in View
Using Refs Array
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Scroll effect - runs when selection changes
useEffect(() => {
const selectedItem = itemRefs.current[selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({
block: 'nearest', // Minimal scroll - only scroll if needed
behavior: 'smooth' // Smooth animation
});
}
}, [selectedIndex]);
// Assign refs in render
{filteredItems.map((item, index) => (
<button
key={index}
ref={el => { itemRefs.current[index] = el; }}
className={index === selectedIndex ? 'bg-blue-100' : ''}
>
{item.label}
</button>
))}
Alternative: Single Ref for Selected Item
const selectedItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen && selectedItemRef.current) {
selectedItemRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}, [isOpen, selectedIndex]);
// Only assign ref to selected item
<button
ref={index === selectedIndex ? selectedItemRef : null}
>
Why block: 'nearest'?
'nearest'only scrolls if the element is outside the visible area'center'would scroll even when item is already visible, causing jarring movement'start'or'end'would always align to top/bottom
Filtering with Shortcut Matching
const filteredCommands = commands.filter(command => {
const q = query.toLowerCase().trim();
if (!q) return true;
// Standard label matching
if (command.label.toLowerCase().includes(q)) return true;
// Shortcut matching: "gd" matches "g d", "gb" matches "g b"
if (command.shortcut) {
const shortcutNoSpaces = command.shortcut.toLowerCase().replace(/\s+/g, '');
if (shortcutNoSpaces.startsWith(q) || shortcutNoSpaces.includes(q)) {
return true;
}
}
// For numbered items (PRs, issues), match by number
if (command.type === 'pr') {
const numberMatch = q.match(/^#?(\d+)$/);
if (numberMatch) {
return String(command.pr.number).startsWith(numberMatch[1]);
}
}
return false;
});
Why strip spaces from shortcuts?
Users type continuously without spaces. Shortcut "g d" should match when user types "gd".
Preventing Re-Render Loops
Command palettes often suffer from infinite re-renders when command objects are recreated every render.
Problem: Unstable References
// BAD: Icons recreated every render
function usePageCommands() {
const commands = useMemo(() => [{
label: 'Sync',
icon: <RefreshCw size={16} />, // New element every render!
action: () => onSync(), // New function every render!
}], [onSync]); // Even with deps, icon is new
useRegisterCommands(commands); // Triggers re-registration → re-render loop
}
Solution: Stable References
// GOOD: Icons defined OUTSIDE component
const refreshIcon = <RefreshCw size={16} />;
const refreshSpinIcon = <RefreshCw size={16} className="animate-spin" />;
const noop = () => {};
function usePageCommands({ onSync, isSyncing }: Props) {
// Memoize handlers
const handleSync = useCallback(() => onSync?.(), [onSync]);
const commands = useMemo(() => [{
label: isSyncing ? 'Syncing...' : 'Sync',
icon: isSyncing ? refreshSpinIcon : refreshIcon, // Stable references
action: isSyncing ? noop : handleSync, // noop, not undefined
}], [isSyncing, handleSync]);
useRegisterCommands(commands);
}
Label-Based Change Detection
Instead of comparing object references, compare by labels:
export function useRegisterCommands(commands: CommandItem[]) {
const { registerCommands, unregisterCommands } = useCommandPalette();
// Create stable ID based on LABELS, not object references
const commandIds = useMemo(
() => commands.map(c => {
if (c.type === 'nav') return `nav:${c.path}`;
return `action:${c.label}`;
}).sort().join('|'),
[commands]
);
const commandsRef = useRef<CommandItem[]>(commands);
useEffect(() => { commandsRef.current = commands; });
const prevIdsRef = useRef<string>('');
useEffect(() => {
// Only register if structure actually changed
if (commandIds !== prevIdsRef.current) {
registerCommands(commandsRef.current);
prevIdsRef.current = commandIds;
return () => unregisterCommands(commandsRef.current);
}
}, [commandIds, registerCommands, unregisterCommands]);
}
Command Type Patterns
type CommandItem =
| { type: 'action'; label: string; icon?: React.ReactNode; action: () => void; shortcut?: string }
| { type: 'nav'; label: string; icon?: React.ReactNode; path: string; shortcut?: string }
| { type: 'file'; file: FileType; label: string; icon?: React.ReactNode }
| { type: 'pr'; pr: PRType; label: string; icon?: React.ReactNode };
// Execute based on type
function executeCommand(command: CommandItem) {
switch (command.type) {
case 'action':
command.action();
break;
case 'nav':
navigate(command.path);
break;
case 'file':
onFileSelect(command.file);
break;
case 'pr':
navigate(`/repos/${command.owner}/${command.repo}/pulls/${command.pr.number}`);
break;
}
}
Common Mistakes
| Mistake | Why It Fails | Fix |
|---|---|---|
| Icons inside useMemo | New icon element every render | Define icons as constants outside component |
| Not resetting index on filter | Arrow keys start from wrong position | setSelectedIndex(0) in onChange |
block: 'center' in scrollIntoView |
Jarring scroll when item already visible | Use block: 'nearest' |
Missing e.preventDefault() |
Arrow keys scroll page AND move selection | Add preventDefault for ArrowUp/Down |
| Forgetting cleanup in useEffect | Event listeners accumulate | Return cleanup function |
undefined for disabled action |
Type error or click does nothing | Use noop constant |
Using { capture: true } on window listener |
Not needed and can cause issues | Use simple addEventListener without options |
| Focusing a container instead of input | Typing won't work, UX feels broken | Use autoFocus on input, window listener handles arrows |
setTimeout for focus |
Race conditions, focus may fail | Use autoFocus attribute on input |
onKeyDown on input element |
Works but less reliable than window | Use window.addEventListener in useEffect |
| Using refs to avoid re-registering listener | Stale closures, missed updates | Include deps in array, let listener re-register |
if (!isOpen) return null after useEffect |
Effects run even when closed, listener always active | Use wrapper component pattern (see above) |
bg-transparent with conditional bg-accent-light |
Tailwind CSS conflict - both set background-color, compiled order wins | Put background classes in conditional: ${selected ? 'bg-accent-light' : 'bg-transparent hover:bg-gray-100'} |
Testing Checklist
- [ ] Cmd+K opens palette, Escape closes
- [ ] Arrow Down moves to next item (stops at last)
- [ ] Arrow Up moves to previous item (stops at first)
- [ ] Enter executes selected command and closes palette
- [ ] Selected item scrolls into view when navigating long lists
- [ ] Typing resets selection to first matching item
- [ ] Shortcuts like "gd" match commands with shortcut "g d"
- [ ] No console errors about re-renders or maximum update depth