Command Palette | Component Deep Dive #34: Command Palette
A command palette isn't a search box. It's the keyboard shortcut gateway to your entire app — fuzzy matching, grouped rendering, keyboard navigation, and nested commands. All four layers are essential.
You've used VS Code's Ctrl+Shift+P, or Linear's Cmd+K. The command palette has spread from developer tools to nearly every modern web app. Its core value: let power users bypass UI hierarchy and reach any feature directly via keyboard.
Core Architecture
A command palette consists of four subsystems:
1. Command Registry — Each command is an object: { id, title, category, keywords, shortcut, action, icon }. The keywords field is critical for fuzzy search — typing "dark" should match "Toggle Dark Mode" even if the title doesn't contain "dark."
2. Fuzzy Matching Engine — The simplest implementation is substring matching, but the experience falls short. Real fuzzy matching lets you type "dmt" to match "Toggle Dark Mode Theme." The algorithm checks whether each query character appears in the target string in order, then scores based on consecutive match length and position.
3. Keyboard Navigation — Arrow keys move the selection, Enter executes, Escape closes. The tricky part is focus management with virtualized lists: when you have 100 results but only 10 in the DOM, arrow key indices must map to the virtual list's global index, not the DOM's local index.
4. Grouping and Nesting — Commands render grouped by category, sorted by relevance within groups. Advanced palettes support nesting: type > for command mode, @ for file mode, # for symbol mode. Each mode has its own command registry.
The Fuzzy Matching Algorithm
The most popular lightweight approach is a JavaScript port of the fzf algorithm:
function fuzzyMatch(query, target) {
if (!query) return { score: 0, matches: [] };
const q = query.toLowerCase();
const t = target.toLowerCase();
let score = 0;
let qi = 0;
let streak = 0;
const matches = [];
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
streak++;
score += 1 + streak * 0.5;
if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-' || t[ti - 1] === '_') {
score += 10;
}
matches.push(ti);
qi++;
} else {
streak = 0;
}
}
if (qi === q.length) {
score -= (t.length - q.length) * 0.1;
return { score, matches };
}
return { score: -1, matches: [] };
}
Key design: consecutive matches get a streak bonus, word boundary matches get a boundary bonus, shorter target strings score higher (length penalty). This scoring ensures "dmt" matches "Toggle Dark Mode Theme" with a higher score than "dont_matter_things."
Keyboard Navigation Implementation
class CommandPalette {
constructor() {
this.selectedIndex = 0;
this.results = [];
this.maxVisible = 8;
this.scrollTop = 0;
}
handleKeydown(e) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
this.ensureVisible();
break;
case 'ArrowUp':
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.ensureVisible();
break;
case 'Enter':
e.preventDefault();
if (this.results[this.selectedIndex]) {
this.execute(this.results[this.selectedIndex]);
}
break;
case 'Escape':
this.close();
break;
}
}
ensureVisible() {
const start = Math.max(0, this.selectedIndex - this.maxVisible + 1);
const end = Math.min(this.results.length, start + this.maxVisible);
this.renderSlice(start, end);
const itemOffset = this.selectedIndex - start;
this.scrollToItem(itemOffset);
}
}
Debounce and Performance
Every keystroke triggers a re-search. If your registry has 500 commands, that's 500 fuzzy matches per keystroke — negligible in modern browsers (<1ms). But with thousands of commands or more complex matching (e.g., searching file contents), debounce becomes necessary:
const debouncedSearch = debounce((query) => {
this.results = this.commands
.map(cmd => ({ ...cmd, score: fuzzyMatch(query, cmd.title + ' ' + cmd.keywords).score }))
.filter(cmd => cmd.score >= 0)
.sort((a, b) => b.score - a.score)
.slice(0, 50);
this.selectedIndex = 0;
this.render();
}, 50);
50ms debounce filters rapid key mashing without perceptible delay.
Focus Trap
When the palette is open, Tab should not escape it:
trapFocus(e) {
if (e.key !== 'Tab') return;
e.preventDefault();
this.input.focus();
}
Most palettes reserve Tab for autocomplete rather than focus cycling — different from traditional modal dialog Tab behavior.
Nested Command Modes
VS Code's palette uses a prefix system: > for commands, @ for symbols, # for global search:
detectMode(query) {
if (query.startsWith('>')) return { mode: 'commands', query: query.slice(1).trim() };
if (query.startsWith('@')) return { mode: 'symbols', query: query.slice(1).trim() };
if (query.startsWith('#')) return { mode: 'search', query: query.slice(1).trim() };
return { mode: 'all', query };
}
Each mode switches to a different command registry with different grouping and sorting logic. This design lets one input box serve multiple purposes without extra UI.
Practical Tips
-
Global shortcut listener: Cmd+K / Ctrl+K should trigger from any focus state — use
document.addEventListener('keydown', ...)not component-level listeners - Recency sorting: Commands with equal scores should sort by last-used time — frequently used commands should float to the top
- Empty state design: When the input is empty, show recently used commands and suggestions, not a blank panel
- Mobile graceful degradation: Mobile devices lack physical keyboards, reducing the palette's value. Consider a floating search button instead, or hide the Cmd+K hint entirely
Top comments (0)