Claude Skill

implementing-command-palettes

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

LLM Mart · 0 points · 14 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agentworkforce-relay-.claude_skills_implementing-command-palettes-a8d2cab.zip · 4 KB
Part of agentworkforce/relay — 57 skills

Install

skills CLI npx skills add https://github.com/AgentWorkforce/relay/tree/main/.claude/skills/implementing-command-palettes
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agentworkforce-relay@llmmart
Git git clone https://github.com/AgentWorkforce/relay.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agentworkforce/relay collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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
Files (relay)
  • SKILL.md 13.8 KB
    ---
    name: implementing-command-palettes
    description: 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
    ---
    
    # 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:
    
    ```tsx
    // 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
    
    ```tsx
    // 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
    
    ```tsx
    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
    
    ```tsx
    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
    
    ```tsx
    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
    
    ```tsx
    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
    
    ```tsx
    // 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
    
    ```tsx
    // 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:
    
    ```tsx
    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
    
    ```tsx
    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
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related