jpskill.com
🎨 デザイン コミュニティ

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本体の挙動とは独立した参考情報です。

⚡ おすすめ: コマンド1行でインストール(60秒)

下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。

🍎 Mac / 🐧 Linux
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
🪟 Windows (PowerShell)
$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. 1. 下の青いボタンを押して implementing-command-palettes.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → implementing-command-palettes フォルダができる
  3. 3. そのフォルダを C:\Users\あなたの名前\.claude\skills\(Win)または ~/.claude/skills/(Mac)へ移動
  4. 4. Claude Code を再起動

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

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 null AFTER 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 page
  • Math.min/max prevents index going out of bounds
  • Effect depends on filteredItems so navigation updates when filter changes
  • Use autoFocus on input, NOT setTimeout(() => 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