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

242 stars

Best use case

implementing-command-palettes is best used when you need a repeatable AI agent workflow instead of a one-off prompt. It is especially useful for teams working in multi. 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

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

Users should expect a more consistent workflow output, faster repeated execution, and less time spent rewriting prompts from scratch.

Practical example

Example input

Use the "implementing-command-palettes" skill to help with this workflow task. Context: 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

Example output

A structured workflow result with clearer steps, more consistent formatting, and an output that is easier to reuse in the next run.

When to use this skill

  • Use this skill when you want a reusable workflow rather than writing the same prompt again and again.

When not to use this skill

  • Do not use this when you only need a one-off answer and do not need a reusable workflow.
  • Do not use it if you cannot install or maintain the related files, repository context, or supporting tools.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/implementing-command-palettes/SKILL.md --create-dirs "https://raw.githubusercontent.com/aiskillstore/marketplace/main/skills/agentworkforce/implementing-command-palettes/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/implementing-command-palettes/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How implementing-command-palettes Compares

Feature / Agentimplementing-command-palettesStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

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

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# 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

Related Skills

routeros-command-tree

242
from aiskillstore/marketplace

RouterOS command tree introspection via /console/inspect API. Use when: building tools that parse RouterOS commands, generating API schemas from RouterOS, working with /console/inspect, mapping CLI commands to REST verbs, traversing the RouterOS command hierarchy, or when the user mentions inspect, command tree, RAML, or OpenAPI generation for RouterOS.

command-creator

242
from aiskillstore/marketplace

This skill should be used when creating a Claude Code slash command. Use when users ask to "create a command", "make a slash command", "add a command", or want to document a workflow as a reusable command. Essential for creating optimized, agent-executable slash commands with proper structure and best practices.

pentest-commands

242
from aiskillstore/marketplace

This skill should be used when the user asks to "run pentest commands", "scan with nmap", "use metasploit exploits", "crack passwords with hydra or john", "scan web vulnerabilities with nikto", "enumerate networks", or needs essential penetration testing command references.

command-management

242
from aiskillstore/marketplace

Use PROACTIVELY this skill when you need to create or update custom commands following best practices

when-creating-slash-commands-use-slash-command-encoder

242
from aiskillstore/marketplace

Create ergonomic slash commands for fast access to micro-skills with auto-discovery and parameter validation

slash-command-encoder

242
from aiskillstore/marketplace

Creates ergonomic slash commands (/command) that provide fast, unambiguous access to micro-skills, cascades, and agents. Enhanced with auto-discovery, intelligent routing, parameter validation, and command chaining. Generates comprehensive command catalogs for all installed skills with multi-model integration.

building-commands

242
from aiskillstore/marketplace

Expert at creating and modifying Claude Code slash commands. Auto-invokes when the user wants to create, update, modify, enhance, validate, or standardize slash commands, or when modifying command YAML frontmatter fields (especially 'model', 'allowed-tools', 'description'), needs help designing command workflows, or wants to understand command arguments and parameters. Also auto-invokes proactively when Claude is about to write command files (*/commands/*.md), or implement tasks that involve creating slash command components.

implementing-with-tdd

242
from aiskillstore/marketplace

Use when implementing bug fixes, features, or any code changes where test-first development is appropriate.

designing-and-implementing

242
from aiskillstore/marketplace

Use when receiving feature requests, architectural discussions, or multi-step implementation needs that require design before coding.

command-development

242
from aiskillstore/marketplace

This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.

command-optimization

242
from aiskillstore/marketplace

CLI command development specialist. Use when creating commands, designing argument parsing, automating tasks, or implementing CLI best practices. Specializes in command design patterns and user experience.

implementing-code

242
from aiskillstore/marketplace

Implements code changes and creates commits. Triggered when: implementation tasks, code changes, feature additions, bug fixes.