Building a Cmd+K Command Palette: Global Shortcuts, Grouped Search, and Keyboard-Only Navigation

Build a Cmd+K command palette in plain JS — global shortcut, grouped search, keyboard navigation. Free snippet, exports to React, Vue, Angular.
Cmd+K Command Palette: Global Shortcuts, Grouped Search, and Keyboard-Only Navigation

There's a specific moment in a product's growth where the navigation menu stops working. Not literally — the links still click — but the number of things a user might want to do has outgrown the number of things that fit in a sidebar. So features get nested three menus deep, or a search icon gets bolted onto the header, and both solve the problem badly. VS Code, Linear, Raycast, GitHub and Notion all converged on the same fix: press Cmd+K (or Ctrl+K), type a few characters, hit Enter. No hierarchy to remember, no hunting — just naming the thing you want.

The interesting part isn't the idea, it's that a real, keyboard-complete implementation is genuinely small — one global keydown listener, one array of commands, one filter function, and about a dozen lines of arrow-key bookkeeping. Nothing here needs a fuzzy-search library or a UI framework. Here it is live: click into the preview and press Cmd+K (or the button), type to filter, and use the arrow keys and Enter without touching the mouse.

Grab the code, or open the full editor with live HTML/CSS/JS panels: Command Palette on FWD Tools. It's plain HTML, CSS and JavaScript with zero dependencies, and the same editor exports one-click to React, Vue, Angular and React + Tailwind.

In this post I'll build the whole thing from the keydown listener up: how a global shortcut avoids stepping on the browser's own Cmd+K, how commands are grouped and rendered from one flat array, how the substring filter works and where it falls short of real fuzzy matching, how arrow-key navigation keeps a highlighted index in sync with the DOM, and what changes when you wire the placeholder alert() up to real actions.

What the component actually is

Four pieces, and the order they matter in:

  1. A global keydown listener that works no matter what's focused on the page — the whole point of a command palette is that you don't have to click anything first.
  2. A flat array of command objects, each with a group, an icon, a name, a description and an optional shortcut — the single source of truth for what's searchable.
  3. A filter-and-render pass that turns a search query into a grouped, re-rendered list on every keystroke.
  4. An active-index tracker that keeps arrow-key navigation, mouse hover, and Enter all pointing at the same command.

Swap the COMMANDS array and the same four pieces become a file finder, a settings search, or a slash-command menu — the mechanics don't care what's being searched.

Where you'd actually use this

  • SaaS dashboards with deep navigation. Replace three levels of nested menu with one search box — see the mega menu for the alternative you're often replacing.
  • Developer tools. Power users expect Cmd+K by now; pair it with a keyboard shortcuts cheat sheet for the rest of the app.
  • Any app where "find the thing" beats "remember where the thing lives." Settings pages, admin panels, documentation sites — anywhere the number of destinations has outgrown a visible menu.
  • Learning keyboard event handling. metaKey vs ctrlKey detection, preventDefault(), and dynamic DOM rendering from a data array are all here in isolation, without a framework's event system in the way.

The markup: an overlay, a search row, and an empty list

<div class="overlay" id="overlay" onclick="closePalette()"></div>
<div class="palette" id="palette" role="dialog" aria-modal="true">
  <div class="search-row">
    <svg ...></svg>
    <input id="cmd-q" class="cmd-input" placeholder="Search commands…" oninput="filter(this.value)" autocomplete="off" />
    <kbd class="esc-key" onclick="closePalette()">ESC</kbd>
  </div>
  <div id="cmd-list" class="cmd-list"></div>
</div>

Two elements do the structural work: #overlay, a full-screen backdrop that closes the palette on click, and #palette, the dialog itself with role="dialog" and aria-modal="true" so assistive tech understands it as a modal. Both start hidden with CSS opacity and pointer-events: none rather than display: none — the reason is purely about the open animation: a scale-and-fade transition needs the element in the layout to animate, and toggling one .show class on each element is simpler than juggling display timing around a CSS transition.

Notice what's not in the markup: the command list. #cmd-list starts empty and is filled entirely by JavaScript from data — there is no hardcoded HTML per command to keep in sync with the search results.

Step 1 — The command data

const COMMANDS = [
  { group: 'Navigation', icon: '🏠', name: 'Go to Home',        desc: 'Return to the main dashboard',  shortcut: 'G H' },
  { group: 'Navigation', icon: '📊', name: 'Open Dashboard',    desc: 'View analytics overview',       shortcut: 'G D' },
  { group: 'Actions',    icon: '➕', name: 'New Document',       desc: 'Create a blank document',       shortcut: '⌘N' },
  { group: 'Actions',    icon: '📤', name: 'Export as PDF',      desc: 'Download current page as PDF',  shortcut: '⌘E' },
  { group: 'Theme',      icon: '🌙', name: 'Toggle Dark Mode',   desc: 'Switch color scheme',           shortcut: '⌘T' },
  { group: 'Help',       icon: '📖', name: 'Documentation',      desc: 'Open the docs in a new tab',   shortcut: '?' },
];

Plain objects, nothing clever. This is the whole reason the component stays simple: adding a command is adding an object to an array, not writing new markup. The group field is what makes the grouped-headers UI possible — it isn't a separate data structure, it's just a field that render() reads back out.

Step 2 — Rendering grouped results from a flat array

let activeIdx = 0, visible = [];

function render(cmds) {
  visible = cmds;
  activeIdx = 0;
  const el = document.getElementById('cmd-list');
  if (!cmds.length) { el.innerHTML = '<div class="cmd-empty">No commands found</div>'; return; }
  const groups = [...new Set(cmds.map(c => c.group))];
  el.innerHTML = groups.map(g => {
    const items = cmds.filter(c => c.group === g).map((c, i) => {
      const gi = cmds.indexOf(c);
      return `<div class="cmd-item${gi === 0 ? ' active' : ''}" data-idx="${gi}" onclick="pick(${gi})">` +
        `<div class="cmd-icon">${c.icon}</div>` +
        `<div class="cmd-text"><div class="cmd-name">${c.name}</div><div class="cmd-desc">${c.desc}</div></div>` +
        `${c.shortcut ? `<span class="cmd-shortcut">${c.shortcut}</span>` : ''}</div>`;
    }).join('');
    return `<div class="cmd-group-label">${g}</div>${items}`;
  }).join('');
}

The grouping trick is [...new Set(cmds.map(c => c.group))] — collect every group name that appears in the currently filtered list, deduplicated by Set, in the order they first appear. That's deliberate: if a search matches only "Actions" commands, only the "Actions" header renders — an empty "Navigation" section never shows up, because the groups list is derived from what's visible, not from a fixed list of all possible groups.

Each item's data-idx attribute holds its position in the flat cmds array (found with cmds.indexOf(c)), not its position within its group. That index is what ties the visual grouping back to a single flat list arrow-key navigation can walk in order, regardless of which group boundary it crosses.

Also worth noting: render() resets activeIdx to 0 every time it's called. That means every new search automatically highlights the first result — there's no separate "reset selection" step to remember to call elsewhere.

Step 3 — The search filter

function filter(q) {
  const lq = q.toLowerCase();
  render(q ? COMMANDS.filter(c => c.name.toLowerCase().includes(lq) || c.desc.toLowerCase().includes(lq)) : COMMANDS);
}

This is a substring filter, not fuzzy search, and it's worth being honest about the difference. includes() checks whether the query appears as a contiguous run of characters — typing "dash" matches "Open Dashboard" because "dash" is literally inside "Dashboard." A real fuzzy matcher (the kind VS Code and Raycast use) would also match "opndsh" by checking that each query character appears somewhere in the target, in order, not necessarily adjacent. That's a meaningfully different algorithm — a small state machine walking both strings in parallel — and it's the first thing worth asking an AI assistant to add once the substring version feels too strict.

Searching both name and desc in the same || is a small but real usability choice: a user typing "analytics" finds "Open Dashboard" because the word "analytics" lives in its description, not its name. Restricting the search to just name would miss that.

Step 4 — Keyboard navigation and the active index

function setActive(i) {
  document.querySelectorAll('.cmd-item').forEach(el => el.classList.remove('active'));
  const el = document.querySelector(`[data-idx="${i}"]`);
  if (el) { el.classList.add('active'); el.scrollIntoView({ block: 'nearest' }); activeIdx = i; }
}

document.addEventListener('keydown', e => {
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openPalette(); return; }
  if (!document.getElementById('palette').classList.contains('show')) return;
  if (e.key === 'Escape') closePalette();
  if (e.key === 'ArrowDown') { e.preventDefault(); setActive(Math.min(activeIdx + 1, visible.length - 1)); }
  if (e.key === 'ArrowUp')   { e.preventDefault(); setActive(Math.max(activeIdx - 1, 0)); }
  if (e.key === 'Enter') pick(activeIdx);
});

One keydown listener does everything, and the order of its checks matters. The Cmd+K check runs first, unconditionally — it has to work whether the palette is open, closed, or the user is focused on some unrelated input, which is why it's not gated behind any other condition. Every check after it returns early if the palette isn't open, so none of the arrow-key or Enter logic accidentally fires while the palette is hidden and the user is just navigating the rest of the page with arrow keys.

setActive(i) is the single function every navigation path funnels through — arrow keys call it, and so does mouse hover (via a corresponding CSS :hover rule mirrored by a JS listener in the full source). It does three things atomically: strips .active from every item, adds it to the one matching data-idx="{i}", and calls scrollIntoView({ block: 'nearest' }) so a highlighted item that's scrolled out of view snaps back into the visible area without over-scrolling past it. Math.min and Math.max clamp the index at the list boundaries instead of wrapping — pressing ArrowDown at the last item just stays there, which is the expected behavior in every palette this pattern is modeled on.

Step 5 — Opening, closing, and the deliberate focus delay

function openPalette() {
  document.getElementById('overlay').classList.add('show');
  document.getElementById('palette').classList.add('show');
  document.getElementById('cmd-q').value = '';
  render(COMMANDS);
  setTimeout(() => document.getElementById('cmd-q').focus(), 50);
}
function closePalette() {
  document.getElementById('overlay').classList.remove('show');
  document.getElementById('palette').classList.remove('show');
}

Opening resets the search value and re-renders the full unfiltered list every time — the palette never remembers your last query, which matches how every real-world implementation behaves; a stale filtered list from your last search would be confusing to land on. The 50ms setTimeout before focusing the input looks arbitrary but isn't: focusing an element the same tick you toggle its container's opacity from 0 to 1 can fight with the CSS transition in some browsers, so the short delay lets the open animation actually start before the input steals focus and the cursor starts blinking.

Step 6 — Executing a command

function pick(i) { alert('Running: ' + visible[i]?.name); closePalette(); }

This is the one line meant to be replaced. visible — not COMMANDS — is what pick() reads from, because activeIdx is an index into whatever list is currently filtered and displayed, not the full command set. Swapping the alert() for real behavior is a small refactor: build a map from command name to a handler function, and call handlers[visible[i]?.name]?.() instead. A router-backed app would have handlers that call router.push('/dashboard'); a settings palette would have handlers that open a specific panel.

Customizing it for your own project

  • Add real fuzzy matching. Replace the includes() check with a routine that walks the query and target characters in parallel, matching out-of-order and non-adjacent characters — this is what turns "cmpl" into a match for "Command Palette."
  • Add recently-used commands. Track the last few executed command names in an array (or localStorage), and prepend a "Recent" group to the rendered list when the search query is empty.
  • Debounce for large command sets. At eleven commands, filtering on every keystroke is free. At a few hundred — file names, API endpoints — rebuilding the whole innerHTML string per keystroke starts to cost something; a short debounce or a virtualized list (see the virtual scroll list technique) fixes that.
  • Support multi-step commands. Some commands need a follow-up input — "Rename to…" prompts for text before executing. That's a second screen inside the same dialog, not a new component.
  • Wire it to a real router or action dispatcher. Replace the alert() with an actions map, as described above, so pick() actually does something.

The things deliberately left out

Real fuzzy search. As covered above, the shipped version is a plain substring filter. It's a completely reasonable default for a command set under a few dozen entries, and it's the piece most worth upgrading first as the list grows.

Persistence between sessions. Recently-used commands, pinned favorites, and custom keyboard shortcuts all reset on reload here. Real implementations back these with localStorage or a user preferences API.

Nested or multi-level commands. Every command here is a single, flat, terminal action. Tools like Raycast support commands that open a sub-palette (search Home, then search files matching whatever you typed). That's a genuinely different interaction model — a stack of palette states rather than one flat list — and worth building only once the flat version stops being enough.

Using it in React, Vue, or Angular

The editor exports all four, and the shape of the port is consistent. open, the search query, and the filtered results all become component state; the global Cmd+K listener moves into a mount-time effect (useEffect with cleanup in React, onMounted/onUnmounted in Vue, ngOnInit/ngOnDestroy in Angular) so the listener is added once and removed on unmount rather than leaking. The filtered-and-grouped list becomes a derived value — useMemo keyed on the query in React, a computed in Vue — recomputed from COMMANDS rather than mutated in place. The one thing to actively unlearn is the DOM-string building in render(): in a framework, you map the filtered array to elements directly and let the framework's own reconciliation handle updates, rather than hand-writing an HTML string and reassigning innerHTML.

Build, understand, optimize, and extend it with AI

This snippet is small enough to read start to finish in a few minutes, which makes it a good one to interrogate rather than skim. Paste its HTML, CSS and JS into an assistant like Claude and start with the listener ordering: ask it to explain exactly why the Cmd+K check runs before the "is the palette open" guard, and what would break if those two checks were swapped. Then ask it to trace setActive() and explain why it operates on data-idx attributes read from the DOM rather than keeping a parallel array of element references — and whether that choice would still hold up if the list were virtualized. For optimization, ask whether rebuilding the full innerHTML string on every keystroke is fine at eleven commands but would need a different approach — DOM diffing, or a windowed render — at a few hundred, and have it sketch what that would look like. For extension, in roughly increasing order of difficulty: replace the substring filter with real fuzzy matching and have it explain the character-walking algorithm it writes; add a "Recent" group backed by localStorage that appears only when the search box is empty; and finally attempt a nested sub-palette, where selecting certain commands pushes a new search context onto a stack instead of executing immediately — which is the same architectural leap every real command palette tool eventually makes. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the component from scratch, or as a jumping-off point for your own variant:

Build a Cmd+K command palette in plain HTML, CSS, and JavaScript — no fuzzy-search library, no UI framework.

Requirements:
- A global keydown listener on document that detects both e.metaKey and e.ctrlKey combined with the "k" key, calls preventDefault to stop the browser's own shortcut, and opens the palette from anywhere on the page without requiring focus to be inside it first.
- A flat array of command objects, each with a group name, an icon, a name, a description, and an optional keyboard shortcut string, rendered by grouping commands under their group label headers dynamically (not hardcoded per-group markup) — and only groups that have at least one currently-visible command should render a header.
- A search input that filters the command array by checking whether the lowercased query is a substring of either the command's name or its description, re-rendering the grouped list on every keystroke and resetting the active selection to the first result each time.
- Full keyboard navigation inside the open palette: ArrowDown and ArrowUp move a highlighted-item index up or down clamped to the list bounds (not wrapping), calling scrollIntoView with block: "nearest" so the highlighted item is always visible without page-level scrolling, Enter executes the highlighted command, and Escape closes the palette.
- An empty state shown when no commands match the current query.
- Open/close must be driven by toggling a class that animates opacity and a scale transform on the palette panel plus a separate blurred backdrop overlay, not by toggling display or visibility directly.
- Focus the search input automatically after opening, with enough delay that the open transition has already started, and clear any previous search query so the palette never reopens showing a stale filtered list.

Final thought

The trick worth keeping from this one isn't the keyboard handling specifically — it's that every entry point into the same piece of state goes through one function. setActive() is the only thing allowed to change which item is highlighted; render() is the only thing allowed to rebuild the list; pick() is the only thing allowed to execute a command. Arrow keys, mouse hover, typing, and Enter are all just different callers of the same three functions, which is exactly why the highlighted item, the visible list, and the executed command can never drift out of sync with each other.

That's the same discipline worth applying to any component with more than one way to trigger the same change — a filter that can be set by a dropdown, a URL param, and a search box; a selected tab that can be set by a click, a keyboard shortcut, and a deep link. One function, many callers, is a smaller idea than "command palette," and it's the one that actually generalizes.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer and the creator of FWD Tools and WebDevPuneet.

Post a Comment