⌘K Command Menu in React: cmdk, Keyboard Nav, Fuzzy Search
Build a ⌘K command palette in React with cmdk — covers keyboard navigation, fuzzy search, grouping, and accessible patterns in under 200 lines.
Why Command Menus Are Worth Your Time
Every power user you care about reaches for ⌘K first. It's not a trend — Linear shipped it in 2020, Vercel followed, and by 2023 the pattern was so expected that apps without one felt unfinished. If your product has more than a dozen actions, a command menu is the single highest-ROI UX upgrade you can ship.
That said, building one from scratch is where most teams underestimate the work. Keyboard focus trapping, ARIA roles, fuzzy matching that doesn't feel broken, animated transitions — it's 400 lines before you've done anything interesting. That's exactly why cmdk exists.
This guide walks through a production-ready command palette using cmdk v1.0. You'll get keyboard navigation, grouped results, fuzzy search, and a close button. No hand-waving, no 'left as an exercise to the reader' nonsense — just working code you can drop in.
Installing cmdk and the Basics
Start with a clean install. cmdk has zero peer deps beyond React 18+.
npm install cmdk
# or
pnpm add cmdkThe library exports a single Command component with a handful of sub-components. Think of it like Radix UI's compositional pattern — you assemble the pieces, it handles all the accessibility plumbing. Worth noting: cmdk v1.0 dropped the old CommandInput prop-based API, so if you're migrating from 0.2.x, read the changelog before copying old snippets.
Here's the minimal shell — a dialog that opens on ⌘K (or Ctrl+K on Windows/Linux):
import { useEffect, useState } from 'react'
import { Command } from 'cmdk'
import './command-menu.css'
export function CommandMenu() {
const [open, setOpen] = useState(false)
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(prev => !prev)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [])
if (!open) return null
return (
<div className="cmdk-overlay" onClick={() => setOpen(false)}>
<Command
className="cmdk-root"
onClick={e => e.stopPropagation()}
>
<Command.Input placeholder="Type a command or search..." />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
</Command.List>
</Command>
</div>
)
}That stopPropagation on the inner Command click is easy to forget. Without it, clicking inside the menu closes it immediately — annoying bug, easy fix.
Keyboard Navigation and Focus Management
Honestly, this is where cmdk earns its keep. The Command.List handles arrow key navigation, Home/End, and wrapping automatically. You get full ARIA listbox + option semantics out of the box. You don't need to wire any of that yourself.
What you *do* need to handle: trapping focus inside the dialog so tab doesn't escape into the page behind it. The simplest approach is wrapping in a Radix Dialog.Root or using the native <dialog> element with modal mode. Here's the Radix version, which also gives you the escape-to-close for free:
import * as Dialog from '@radix-ui/react-dialog'
import { Command } from 'cmdk'
export function CommandMenu({ open, onOpenChange }: {
open: boolean
onOpenChange: (v: boolean) => void
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="cmdk-overlay" />
<Dialog.Content
aria-label="Command menu"
className="cmdk-dialog"
>
<Command>
<Command.Input placeholder="Search commands..." />
<Command.List>
<Command.Empty>Nothing here.</Command.Empty>
{/* items go here */}
</Command.List>
</Command>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}Quick aside: if you're skipping Radix, set autoFocus on Command.Input and add a keydown listener for Escape directly on the Command element. It works, but you're re-implementing what Radix already solved. Your call.
One more thing — make sure Dialog.Content has a reasonable max-height with overflow-y: auto on Command.List. Without it, a long result list will push off-screen. I'd suggest max-height: min(400px, 70vh) as a starting point that holds up across screen sizes.
Fuzzy Search with cmdk's Built-in Filter
cmdk ships with a basic filter that does substring matching by default. It works, but it's not fuzzy — 'set' won't match 'Settings'. For a command palette, you want fuzzy. Replace the default filter prop with something like fuse.js or the tiny fzy package.
import Fuse from 'fuse.js'
const allItems = [
{ value: 'settings-profile', label: 'Open Profile Settings' },
{ value: 'settings-billing', label: 'Billing & Plans' },
{ value: 'new-project', label: 'Create New Project' },
{ value: 'invite-member', label: 'Invite Team Member' },
{ value: 'dark-mode', label: 'Toggle Dark Mode' },
]
const fuse = new Fuse(allItems, {
keys: ['label'],
threshold: 0.4,
})
// Pass a custom filter to Command
<Command
filter={(value, search) => {
if (!search) return 1
const results = fuse.search(search)
const match = results.find(r => r.item.value === value)
return match ? 1 - (match.score ?? 0) : 0
}}
>The filter function receives the item's value string and the current search string. Return 1 to show it at the top, 0 to hide it, anything between to sort it. cmdk sorts results by score automatically.
In practice, a threshold of 0.4 is about right for command labels. Go lower (like 0.2) and you'll miss obvious matches; go higher (like 0.6) and you'll surface garbage. Test it with your actual command names — 'New Project' vs 'nwprj' is the kind of thing you want to validate manually.
For large apps with hundreds of commands, consider also searching by keyboard shortcut label ('⌘N', 'Ctrl+Shift+P') — that alone halves the cognitive load for users who vaguely remember a shortcut but not the menu path. You can throw those into the keys array in Fuse and weight them lower.
Grouping Commands and Adding Icons
Flat lists get ugly fast. Command.Group wraps related items and renders an accessible group label. Nothing complicated here — but there's a gotcha: groups with zero matching items still render an empty heading by default. Fix that with the forceMount prop set to undefined and let cmdk hide them, or filter your groups client-side.
import { FileIcon, SettingsIcon, UsersIcon } from 'lucide-react'
<Command.List>
<Command.Empty>No results.</Command.Empty>
<Command.Group heading="Navigation">
<Command.Item
value="go-home"
onSelect={() => { router.push('/'); setOpen(false) }}
>
<FileIcon size={16} />
<span>Go to Home</span>
</Command.Item>
<Command.Item
value="go-settings"
onSelect={() => { router.push('/settings'); setOpen(false) }}
>
<SettingsIcon size={16} />
<span>Open Settings</span>
</Command.Item>
</Command.Group>
<Command.Group heading="Team">
<Command.Item
value="invite-member"
onSelect={() => openInviteModal()}
>
<UsersIcon size={16} />
<span>Invite Member</span>
<kbd>⌘I</kbd>
</Command.Item>
</Command.Group>
</Command.List>That <kbd> element at the end of the item is purely cosmetic — cmdk doesn't do anything with it. Style it with something like font-family: monospace; font-size: 11px; opacity: 0.6 and position it with margin-left: auto. It's a 5-minute addition that makes the whole palette feel polished.
Look, icons are optional but they dramatically improve scannability. Users can parse an icon in ~50ms; reading a label takes 200ms+. At 16px they're unobtrusive. Lucide React is the obvious pick in 2026 — tree-shakeable, MIT licensed, consistent stroke weight.
Want to integrate this with a design that has real visual flair? Check out the glassmorphism components on Empire UI — a frosted glass command palette backdrop looks significantly better than a plain white modal, especially in dark mode. The glassmorphism generator can give you the exact CSS values.
Styling the Command Palette
cmdk ships with zero default styles — you get raw semantic HTML. That's intentional. Here's a minimal CSS that gives you a centered overlay with a clean card, ready to customize:
.cmdk-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
z-index: 999;
}
[cmdk-root] {
width: 640px;
background: #0f0f0f;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 25px 50px rgba(0,0,0,0.6);
}
[cmdk-input] {
width: 100%;
padding: 16px 20px;
font-size: 16px;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255,255,255,0.08);
color: #fff;
outline: none;
}
[cmdk-item] {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 14px;
color: rgba(255,255,255,0.85);
cursor: pointer;
border-radius: 6px;
margin: 2px 8px;
}
[cmdk-item][aria-selected=true] {
background: rgba(255,255,255,0.08);
color: #fff;
}
[cmdk-group-heading] {
padding: 6px 16px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.35);
text-transform: uppercase;
}
[cmdk-list] {
max-height: 400px;
overflow-y: auto;
padding: 8px 0;
}cmdk uses data attributes ([cmdk-root], [cmdk-input], [cmdk-item]) for CSS targeting. It's a clean pattern — no class name conflicts, no CSS-in-JS required. The aria-selected attribute on items toggles as you arrow-key through them, so that's your hover/focus style hook.
That 15vh top padding is intentional. Centering vertically feels wrong for command menus — your eye expects it in the upper-center of the screen, like Spotlight on macOS. Anything lower than 10vh starts feeling like a modal instead of a palette.
If you want to take the visual design further, browse components on Empire UI for inspiration. There are shimmer input variants, animated borders, and dark-mode-first card designs that pair well with a command palette backdrop. You don't need to build all of this from scratch.
Async Loading and Recent Items
Real apps don't have static command lists. You'll want to load results from an API — think global search across documents, users, or tickets. cmdk doesn't do async by default, but it composes with your state cleanly.
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
setLoading(true)
const controller = new AbortController()
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then(r => r.json())
.then(data => setResults(data.items))
.catch(() => {})
.finally(() => setLoading(false))
return () => controller.abort()
}, [query])
// In JSX:
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Search everything..."
/>
<Command.List>
{loading && <Command.Loading>Searching...</Command.Loading>}
{!loading && results.length === 0 && query && (
<Command.Empty>No results for "{query}"</Command.Empty>
)}
{results.map(item => (
<Command.Item key={item.id} value={item.id} onSelect={() => navigate(item.url)}>
{item.title}
</Command.Item>
))}
</Command.List>That AbortController cleanup is non-negotiable. Without it, a slow query can resolve after a faster one and flash stale results. Classic race condition, completely avoidable.
For the empty state when there's no query, show recent items or pinned commands. Store them in localStorage — a simple array of the last 5 onSelect values, deduped. This alone makes the palette feel intelligent. Users open it, see what they used last time, and often don't even need to type.
Worth noting: debounce your search requests. useDebounce from usehooks-ts with a 200ms delay is enough to avoid hammering your endpoint on every keystroke without making the UI feel laggy.
FAQ
No — cmdk requires client-side state and event listeners. Mark any component using it with 'use client' at the top. The command data can come from server actions, but the palette itself runs in the browser.
cmdk handles role='listbox' and role='option' on items automatically. Wrap it in Radix Dialog to get aria-modal and focus trapping. Make sure your Dialog.Content has aria-label='Command menu' so screen readers announce it on open.
Yes, completely. Replace the [cmdk-root] attribute selectors with Tailwind classes on the components directly, or use @apply in a CSS layer. Most teams combine both — Tailwind for layout, attribute selectors for the cmdk-specific states like aria-selected.
Radix UI's Command component is actually cmdk under the hood — shadcn/ui re-exports it with Radix Dialog integration pre-wired. If you're on shadcn, use their Command primitive. If you're building from scratch, use cmdk directly and compose your own dialog wrapper.