Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 41% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 30% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 91% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 107% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 76% | 0% |
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.
| 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 |
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 (!isOpen) return null AFTER useEffect hooks, the effects still run when closedThe input MUST be focused (for typing to work), and keyboard navigation MUST use window.addEventListener. This works because:
e.preventDefault() just stops page scrollingtsx// 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 }} />
tsxconst [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 pageMath.min/max prevents index going out of boundsfilteredItems so navigation updates when filter changesautoFocus on input, NOT setTimeout(() => ref.current?.focus(), 0)tsxconst 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> )); }
tsxconst 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/bottomtsxconst 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".
Command palettes often suffer from infinite re-renders when command objects are recreated every render.
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 }
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); }
Instead of comparing object references, compare by labels:
tsxexport 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]); }
tsxtype 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; } }
| 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'} |
Other measured skills in the registry, with their headline benchmark lift.