--- 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 ; } // 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
...
; } ``` **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 { 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) => ( ))} ``` ### Alternative: Single Ref for Selected Item ```tsx const selectedItemRef = useRef(null); useEffect(() => { if (isOpen && selectedItemRef.current) { selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth', }); } }, [isOpen, selectedIndex]); // Only assign ref to selected item