Skip to content
Automation
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

From plugin
relay
79017 skills39 agents
Install
$ npx -y skills add AgentWorkforce/relay --skill implementing-command-palettes --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/implementing-command-palettes

Context preview

The summary Claude sees to decide when to auto-load this 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

SKILL.md

implementing-command-palettes.SKILL.md
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:

// 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 fil
Read more
Ships withrelay

Let Claude Code message Codex. Let your Hyperagent talk to your Hermes agent. Give your custom agents a way to message each other.

Get the whole plugin