DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #34: Command Palette — The Cmd+K Pattern Every Modern App Needs

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: [] };
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Global shortcut listener: Cmd+K / Ctrl+K should trigger from any focus state — use document.addEventListener('keydown', ...) not component-level listeners
  2. Recency sorting: Commands with equal scores should sort by last-used time — frequently used commands should float to the top
  3. Empty state design: When the input is empty, show recently used commands and suggestions, not a blank panel
  4. 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)