Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
agentworkforce avatar

Implementing Command Palettes

  • 4 installs
  • 784 repo stars
  • Updated August 4, 2026
  • agentworkforce/relay

implementing-command-palettes is a Claude Code skill that provides React patterns for building Cmd+K command palettes with keyboard navigation and stable renders.

About

implementing-command-palettes is a Claude Code skill for building Cmd+K command palettes in React. It covers arrow-key navigation with a clamped selected index, keeping the selected item visible with scrollIntoView, filtering by label and shortcut, and avoiding re-render loops caused by unstable references. A developer uses it when building or debugging a responsive command palette.

  • React Cmd+K command palette keyboard navigation patterns
  • scrollIntoView to keep the selected item in view during navigation
  • Fixes for infinite re-renders from reference instability

Implementing Command Palettes by the numbers

  • 4 all-time installs (skills.sh)
  • Ranked #1,814 of 2,245 Frontend Development skills by installs in the Skillselion catalog
  • Data as of Aug 5, 2026 (Skillselion catalog sync)
At a glance

implementing-command-palettes capabilities & compatibility

Capabilities
ui component build · keyboard navigation · react state management
Use cases
frontend · ui design
From the docs

What implementing-command-palettes says it does

Command palettes (Cmd+K / Ctrl+K) need precise keyboard navigation, scroll behavior, and stable references to avoid re-render loops.
SKILL.md
npx skills add https://github.com/agentworkforce/relay --skill implementing-command-palettes

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs4
repo stars784
Last updatedAugust 4, 2026
Repositoryagentworkforce/relay

What it does

Build a responsive React Cmd+K command palette with keyboard navigation and stable renders.

Who is it for?

Building or debugging a React Cmd+K palette with keyboard navigation and scroll behavior.

Skip if: Non-React palettes or backend/agent coordination tasks.

When should I use this skill?

When building a Cmd+K command palette in React or fixing its re-render loops.

What you get

A Cmd+K palette navigates with arrow keys, keeps selection in view, and avoids re-render loops.

  • React command palette component
  • Keyboard navigation logic

By the numbers

  • 5-row quick-reference table of palette features and implementations

Files

SKILL.mdMarkdownGitHub ↗

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

FeatureImplementation
Arrow navigationTrack selectedIndex, clamp with Math.min/max
Keep in viewscrollIntoView({ block: 'nearest', behavior: 'smooth' })
Shortcut matchingStrip spaces from shortcuts, match against query
Stable iconsDefine icon elements outside component
Stable handlersuseCallback + 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

MistakeWhy It FailsFix
Icons inside useMemoNew icon element every renderDefine icons as constants outside component
Not resetting index on filterArrow keys start from wrong positionsetSelectedIndex(0) in onChange
block: 'center' in scrollIntoViewJarring scroll when item already visibleUse block: 'nearest'
Missing e.preventDefault()Arrow keys scroll page AND move selectionAdd preventDefault for ArrowUp/Down
Forgetting cleanup in useEffectEvent listeners accumulateReturn cleanup function
undefined for disabled actionType error or click does nothingUse noop constant
Using { capture: true } on window listenerNot needed and can cause issuesUse simple addEventListener without options
Focusing a container instead of inputTyping won't work, UX feels brokenUse autoFocus on input, window listener handles arrows
setTimeout for focusRace conditions, focus may failUse autoFocus attribute on input
onKeyDown on input elementWorks but less reliable than windowUse window.addEventListener in useEffect
Using refs to avoid re-registering listenerStale closures, missed updatesInclude deps in array, let listener re-register
if (!isOpen) return null after useEffectEffects run even when closed, listener always activeUse wrapper component pattern (see above)
bg-transparent with conditional bg-accent-lightTailwind CSS conflict - both set background-color, compiled order winsPut 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

FAQ

How do you keep the selected item visible?

Call scrollIntoView({ block: 'nearest', behavior: 'smooth' }) on the selected item's ref when the selection changes.

How do you avoid effects running when the palette is closed?

Use a wrapper component that returns null before the content component mounts, so effects only run when the palette is open.

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.