EmpireUI
Get Pro
← Blog9 min read#command palette#react#search

Command Palette in React: ⌘K Search, Keyboard Navigation, ARIA

Build a fully accessible ⌘K command palette in React with keyboard navigation, fuzzy search, and correct ARIA roles — no library required.

Developer typing on keyboard with code editor open on monitor

Why Command Palettes Belong in Your App

Command palettes went mainstream in 2016 when VS Code shipped its ⌘P / Ctrl+P quick-open — and power users never looked back. The pattern is simple: one keyboard shortcut surfaces a floating search-and-action menu so you can jump anywhere without touching the mouse. Figma does it. Linear does it. GitHub does it. If your app has more than a handful of routes or actions, yours probably should too.

Honestly, the reason most apps still don't have one is that developers assume it's a complex thing to build. It's not. You need a modal overlay, a text input, a filtered list, arrow-key navigation, and a handful of ARIA attributes. That's it. The whole interactive surface fits in around 150 lines of React.

That said, 'simple to describe' doesn't mean 'easy to get right.' The keyboard handling is fiddly, the ARIA contract is weirdly specific, and fuzzy-search feels wrong until you tune it. This article walks through every piece — with working code — so you're not guessing.

Worth noting: if you already use Empire UI for your component library, you can compose this pattern from primitives you probably already have (a Dialog or Modal, a text Input, and an unstyled List). The techniques here apply regardless of your component stack.

Project Setup and State Shape

You don't need a library for this. Plain React with a useReducer (or even useState) is enough for most apps. Here's the shape we'll build toward:

type CommandItem = {
  id: string;
  label: string;
  group?: string;
  icon?: React.ReactNode;
  onSelect: () => void;
};

type PaletteState = {
  open: boolean;
  query: string;
  activeIndex: number;
};

Keep activeIndex in state, not derived from the DOM. Tracking it in React means your keyboard handler and your render function always agree on what's highlighted — no document.querySelectorAll nonsense.

For the useReducer version, your actions are OPEN, CLOSE, SET_QUERY, MOVE_UP, MOVE_DOWN, and SELECT. That's a short list. Each one is a single state transformation. In practice, a flat useState with a few setters works fine for smaller apps — reach for useReducer when you start adding command history or recent-items persistence.

One more thing — keep your commands list stable between renders. Define it outside the component or memoize it with useMemo. You don't want to recompute a 200-item flat list on every keystroke.

Opening with ⌘K and Handling the Keyboard

The global shortcut handler goes in a useEffect on the document. Don't overthink it:

useEffect(() => {
  const handler = (e: KeyboardEvent) => {
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
      e.preventDefault();
      dispatch({ type: 'OPEN' });
    }
    if (e.key === 'Escape') {
      dispatch({ type: 'CLOSE' });
    }
  };
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
}, []);

That e.preventDefault() on ⌘K matters — without it, Chrome tries to focus the address bar on some OS/browser combos. Also note we're listening on document, not a specific element, because the palette needs to open from anywhere in the app.

Inside the open palette, arrow-key navigation lives on the keydown handler of the input element itself:

const handleInputKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    dispatch({ type: 'MOVE_DOWN', total: filtered.length });
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    dispatch({ type: 'MOVE_UP' });
  } else if (e.key === 'Enter') {
    e.preventDefault();
    if (filtered[activeIndex]) {
      filtered[activeIndex].onSelect();
      dispatch({ type: 'CLOSE' });
    }
  }
};

The e.preventDefault() on arrow keys stops the cursor jumping to the start or end of the input text — a tiny detail that users will notice immediately if you miss it. Wrap the index with modulo arithmetic so ArrowDown on the last item loops back to the first. Whether you want that looping behavior is a UX call, but VS Code does it and it feels natural.

Fuzzy Search That Doesn't Feel Terrible

Rolling your own fuzzy search for a command palette is fine. You don't need Fuse.js unless your item list is in the thousands. A simple character-subsequence match is what VS Code uses under the hood and it's fast at any realistic command count:

function fuzzyMatch(query: string, target: string): boolean {
  const q = query.toLowerCase();
  const t = target.toLowerCase();
  let qi = 0;
  for (let i = 0; i < t.length && qi < q.length; i++) {
    if (t[i] === q[qi]) qi++;
  }
  return qi === q.length;
}

// Usage
const filtered = commands.filter((cmd) =>
  fuzzyMatch(query, cmd.label)
);

This is a subsequence match, not a substring match. 'grd' matches 'Gradient Generator' because the letters appear in order. That's the behavior users expect from power tools.

Look, if you want scoring — so that 'grad' ranks 'Gradient Generator' above 'Advanced Grid Layout' — add a score function that counts consecutive character runs. Higher consecutive runs = better score. Sort descending before rendering. Here's the quick version:

function scoreMatch(query: string, target: string): number {
  const q = query.toLowerCase();
  const t = target.toLowerCase();
  let score = 0, streak = 0, qi = 0;
  for (let i = 0; i < t.length && qi < q.length; i++) {
    if (t[i] === q[qi]) {
      streak++;
      score += streak;
      qi++;
    } else {
      streak = 0;
    }
  }
  return qi === q.length ? score : -1;
}

A score of -1 means no match. Sort by score descending and slice to your display limit (24 items is a reasonable cap). This whole thing runs in microseconds for command lists under 1,000 entries. You won't need Web Workers.

ARIA: Getting the Accessibility Contract Right

This is where most implementations fall short. The correct ARIA pattern for a command palette is combobox + listbox + option. Not dialog + list. Not a random role='menu'. The combobox pattern exists precisely for 'text input that controls a popup list' — which is exactly what you have.

// The input
<input
  role="combobox"
  aria-expanded={open}
  aria-haspopup="listbox"
  aria-controls="cmd-listbox"
  aria-activedescendant={filtered[activeIndex]?.id}
  aria-autocomplete="list"
  autoComplete="off"
  value={query}
  onChange={(e) => dispatch({ type: 'SET_QUERY', query: e.target.value })}
  onKeyDown={handleInputKeyDown}
/>

// The list
<ul
  id="cmd-listbox"
  role="listbox"
  aria-label="Commands"
>
  {filtered.map((item, i) => (
    <li
      key={item.id}
      id={item.id}
      role="option"
      aria-selected={i === activeIndex}
      onMouseDown={(e) => e.preventDefault()} // prevent input blur
      onClick={() => { item.onSelect(); dispatch({ type: 'CLOSE' }); }}
    >
      {item.label}
    </li>
  ))}
</ul>

The aria-activedescendant is the key binding. Screen readers track which list item is 'active' by watching this attribute on the combobox input — they don't need focus to actually move to the <li>. That's why you don't want to use tabIndex tricks to move focus into the list on arrow key presses. Keep focus on the input, update aria-activedescendant, and the AT does the rest.

The onMouseDown with e.preventDefault() on each list item deserves a callout. Without it, clicking a result fires blur on the input before click fires on the item — and if your blur handler closes the palette, the click never lands. This is a 2018-era React gotcha that still bites people in 2026.

Quick aside: wrap the whole palette in a <dialog> element with role='dialog' and aria-modal='true' for the outer backdrop. Keep role='combobox' on the input inside. The two roles coexist fine, and the <dialog> gives you free Escape-key handling in browsers that support it natively — you can lean on dialog.showModal() instead of your own open/close state if you want to go that route.

Styling: Making It Feel Native

The visual treatment matters more than you'd think. A command palette that looks like a generic modal feels wrong even if it works perfectly. The palette should feel like it's floating above everything — which means a hard-edged drop shadow, a tight border, and a blur backdrop. Think 24px blur, rgba(0,0,0,0.6) overlay, and a border of around 1px solid rgba(255,255,255,0.1) on dark themes.

.cmd-palette {
  position: fixed;
  top: 20%;
  left: 50%;
  transform: translateX(-50%);
  width: min(640px, 90vw);
  background: rgba(15, 15, 20, 0.85);
  backdrop-filter: blur(24px);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 12px;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
  overflow: hidden;
  z-index: 9999;
}

That backdrop-filter: blur(24px) ties the palette visually to the glassmorphism family — the same technique powering the glassmorphism components in Empire UI. It creates that 'floating above the page' illusion that makes the component feel premium rather than bolted on.

For the active item highlight, avoid pure white or your brand primary at 100% opacity. Something like rgba(99, 102, 241, 0.25) — an indigo wash at low opacity — looks way better than a solid background. It reads as 'selected' without screaming. You can generate the exact gradient or color stop you want using the gradient generator to eyeball values before committing them to CSS.

Animate the palette open with a short scale + fade. scale(0.97) to scale(1.0) over 120ms feels snappy. Go longer than 150ms and it starts feeling sluggish on repeated use — and power users will open this thing dozens of times per session.

Grouping, Icons, and Recent Commands

A flat list of commands works fine for small apps. The moment you have more than ~15 commands you need groups. Render them as labeled sections within the same role='listbox' using role='group' and aria-label:

const grouped = Object.entries(
  filtered.reduce((acc, item) => {
    const group = item.group ?? 'General';
    (acc[group] ??= []).push(item);
    return acc;
  }, {} as Record<string, CommandItem[]>)
);

// In JSX
{grouped.map(([groupLabel, items]) => (
  <li key={groupLabel} role="group" aria-label={groupLabel}>
    <span className="cmd-group-label">{groupLabel}</span>
    <ul>
      {items.map((item, i) => (
        <li key={item.id} role="option" /* ... */ >
          {item.icon && <span aria-hidden="true">{item.icon}</span>}
          {item.label}
        </li>
      ))}
    </ul>
  </li>
))}

Mark icons aria-hidden='true' — they're decorative. The label carries all the semantic meaning. Don't put alt text or aria-label on the icon span; it'll be announced redundantly by screen readers.

Recent commands are a nice touch. Persist the last 5 selected command IDs to localStorage. When the query is empty, show recent commands first rather than the full list. Users love this. It means the palette becomes genuinely faster to use over time — they can open it and hit Enter without typing anything if their last command is still the first result.

In practice, the feature that makes command palettes addictive is not fuzzy search or nice animations. It's that the thing *remembers* what you do. Add recency tracking on day one — it's five lines of code and it changes how users relate to the feature entirely.

FAQ

Should I use cmdk or kbar instead of building from scratch?

Both are solid. cmdk is lighter and more composable; kbar handles command registration and history out of the box. Build from scratch if you need tight control over the ARIA implementation or want to avoid the bundle weight — the core logic is small enough that the DIY version is defensible.

What's the correct ARIA role for a command palette?

Use role='combobox' on the input and role='listbox' / role='option' on the list. Not role='menu' — that's for action menus triggered by buttons, and it has different keyboard expectations that screen readers enforce strictly.

How do I prevent the palette from closing when clicking a result?

Add onMouseDown={(e) => e.preventDefault()} to each list item. This stops the blur event from firing on the input before the click event fires on the item, which is the root cause of the 'click doesn't register' bug.

Is backdrop-filter safe to use for the palette backdrop?

Yes — as of 2025, every major browser ships full backdrop-filter support. Add a @supports not (backdrop-filter: blur(1px)) fallback with a solid semi-transparent background for the edge cases.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

⌘K Command Menu in React: cmdk, Keyboard Nav, Fuzzy SearchSearch With Results Panel in React: Debounce, Highlight, Keyboard NavGlassmorphism Command Palette: ⌘K Frosted Search Modal in ReactFocus Management in React: Trap, Return and Programmatic Focus