TL;DR: cmdk is a headless, keyboard-navigable command palette for React. Add your commands as items, open it with Cmd+K, and users get instant fuzzy-filtered search. Used in Linear, Vercel, and shadcn/ui's Command component. Official docs: cmdk.paco.me.
What is cmdk?
cmdk is the component behind shadcn/ui's <Command> primitive - a fully accessible, keyboard-driven command palette. It renders an input with fuzzy-filtered results that the user navigates with arrow keys. Used by Linear, Vercel, Raycast, and countless other products.
When to use it
Add a command palette when your app has:
- Many navigation options that are hard to discover via menus
- Power users who prefer keyboard-driven workflows
- A global search requirement (Cmd+K / Ctrl+K)
It also works well as a combobox/autocomplete replacement for complex selection UIs.
Key Features
- Fuzzy filtering - built-in substring matching; override with custom
filterfunction - Composable -
Command.Group,Command.Separator,Command.Emptyfor structure - Accessible - ARIA combobox pattern, keyboard navigation, screen reader support
- Controlled/uncontrolled -
value+onValueChangefor controlled mode - Async data - works with any data source; just update the items
Installation
npm install cmdk
Basic Command Palette
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from 'cmdk'
import { useState } from 'react'
function CommandPalette() {
const [open, setOpen] = useState(false)
// Open on Cmd+K / Ctrl+K
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setOpen(prev => !prev)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
if (!open) return null
return (
<div className="fixed inset-0 bg-black/40 flex items-start justify-center pt-[20vh]">
<Command className="w-full max-w-lg bg-white rounded-xl shadow-2xl overflow-hidden border">
<CommandInput placeholder="Type a command or search..." className="px-4 py-3 text-sm outline-none w-full border-b" />
<CommandList className="max-h-72 overflow-y-auto p-2">
<CommandEmpty className="py-6 text-center text-sm text-slate-400">
No results found.
</CommandEmpty>
<CommandGroup heading="Navigation" className="text-xs text-slate-400 px-2 pb-1">
<CommandItem onSelect={() => { navigate('/dashboard'); setOpen(false) }}
className="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer aria-selected:bg-slate-100"
>
Dashboard
</CommandItem>
<CommandItem onSelect={() => { navigate('/settings'); setOpen(false) }}
className="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer aria-selected:bg-slate-100"
>
Settings
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem onSelect={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer aria-selected:bg-slate-100"
>
New document
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</div>
)
}
Custom Filter Function
// Use a better fuzzy matching library
import { matchSorter } from 'match-sorter'
<Command
filter={(value, search) => {
const results = matchSorter([value], search)
return results.length > 0 ? 1 : 0
}}
>
TypeScript Tips
Type your command items when building data-driven palettes:
interface CommandAction {
id: string
label: string
keywords?: string[]
onSelect: () => void
}
function buildCommands(actions: CommandAction[]) {
return actions.map(action => (
<CommandItem
key={action.id}
value={action.label}
keywords={action.keywords}
onSelect={action.onSelect}
>
{action.label}
</CommandItem>
))
}
Common Gotchas
valueprop onCommandItem- this is what the filter matches against, not what's displayed. If your label has emoji or extra markup, setvalueto the plain text string- Groups don't filter themselves - empty groups after filtering still render their heading. Use
CommandGroupwrapped in a visibility check or use theforceMountapproach - shadcn/ui
Command- if you're on shadcn/ui, use its pre-styledCommandcomponent instead of raw cmdk; it handles the styling layer for you
Why command palettes win over deep menus
Think about how you actually find things in Linear or VS Code. You don't hunt through nested menus. You hit Cmd+K, type three letters, and hit enter. That's the whole appeal. A command palette flattens your entire app into one searchable surface, so the cost of adding a new action stays constant no matter how big the app grows.
For users, the payoff is muscle memory. Once someone learns Cmd+K, every action feels equally close. For developers, it's a discoverability tool. Features buried three menus deep suddenly get used because they show up in search. In my experience, adding a palette to an admin-heavy tool changes how people work within a week.
Structuring commands as data
Hard-coding CommandItem elements works for a demo. For a real app, drive the palette from a data array. Each command becomes an object with an id, a label, optional keywords, and a handler. This lets you generate items from your route table, your feature flags, or a permission check, so users only see commands they can actually run.
Keywords are the secret weapon here. The visible label might say "Sign out", but a user might type "logout" or "exit". Add those as keywords and the filter still finds the command. Spend ten minutes adding synonyms and your palette feels much smarter than it really is.
Putting it inside an accessible dialog
A palette is a modal, so it needs proper dialog behavior: focus trapping, an escape-to-close handler, and focus returning to the trigger element on close. cmdk handles the listbox keyboard model - arrow keys, enter to select, the ARIA combobox roles - but it doesn't draw a modal for you. Wrap it in a real dialog primitive so screen reader users get the right announcements and keyboard focus doesn't leak to the page behind it.
Don't forget the overlay click-to-close and a visible focus ring on the selected item. The aria-selected attribute is set for you on the active item, so style that with a clear background. Keyboard users need to see where they are without a mouse hover to guide them.
Handling async and remote search
Static command lists filter instantly on the client. The moment your results come from an API - searching documents, users, or tickets - you switch to controlled mode. Track the input value in state, debounce it by 200 to 300 milliseconds so you don't fire a request per keystroke, fetch, then render the returned items inside CommandList.
Show a loading state while the request is in flight, and turn off cmdk's built-in filtering by setting shouldFilter={false} - the server already did the matching, so client filtering would just hide valid results. One last thing: cancel stale requests. A user typing fast can get an old slow response landing after a newer one, which shows wrong results. Aborting in-flight requests fixes that flicker.
Related
- React Select - for standard dropdown select inputs; cmdk is for keyboard-driven command/search interfaces
- shadcn/ui - shadcn/ui's Command component is built directly on cmdk
- React Hook Form - combine cmdk's Combobox pattern with React Hook Form for searchable form fields