flowchart LR
K["⌘K"] --> P["Command Palette"]
P --> S["Search"]
S --> C["Commands"]
C --> A["Actions"]
style K fill:#3b82f6
style A fill:#10b981
Command Systems: The “Cmd+K” Power User Experience
In a typical web app, you navigate by clicking buttons. In a Pro Tool, you navigate by thinking.
Apps like VS Code, Linear, Notion, and Raycast have trained users to expect a global “Command Palette” (Cmd+K or Ctrl+K). This chapter shows you how to build one—and the underlying architecture that makes it powerful.
The Command Pattern
The Command Pattern separates the intent from the execution:
// packages/shared/src/commands.ts
type Command = {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
shortcut?: string;
keywords?: string[]; // For search matching
perform: () => void | Promise<void>;
enabled?: () => boolean;
};Instead of burying logic in a button’s onClick, you register commands in a central registry.
Why This Matters
| Approach | Discoverability | Keyboard | Scripting | Testing |
|---|---|---|---|---|
| Button onClick | ❌ Hidden | ❌ No | ❌ No | ❌ Hard |
| Command Registry | ✅ Palette | ✅ Shortcuts | ✅ Yes | ✅ Easy |
Command Registry
// src/commands/registry.ts
import { create } from 'zustand';
import type { Command } from '@myproject/shared';
interface CommandStore {
commands: Map<string, Command>;
register: (command: Command) => void;
unregister: (id: string) => void;
execute: (id: string) => Promise<void>;
search: (query: string) => Command[];
}
export const useCommandStore = create<CommandStore>((set, get) => ({
commands: new Map(),
register: (command) => {
set((state) => {
const newCommands = new Map(state.commands);
newCommands.set(command.id, command);
return { commands: newCommands };
});
},
unregister: (id) => {
set((state) => {
const newCommands = new Map(state.commands);
newCommands.delete(id);
return { commands: newCommands };
});
},
execute: async (id) => {
const command = get().commands.get(id);
if (command && (command.enabled?.() ?? true)) {
await command.perform();
}
},
search: (query) => {
const q = query.toLowerCase();
return Array.from(get().commands.values())
.filter((cmd) => {
if (cmd.enabled && !cmd.enabled()) return false;
return (
cmd.label.toLowerCase().includes(q) ||
cmd.description?.toLowerCase().includes(q) ||
cmd.keywords?.some((k) => k.toLowerCase().includes(q))
);
})
.slice(0, 10); // Limit results
},
}));
Registering Commands
// src/commands/project-commands.ts
import { useEffect } from 'react';
import { useCommandStore } from './registry';
import { useNavigate } from 'react-router-dom';
export function useProjectCommands() {
const navigate = useNavigate();
const register = useCommandStore((s) => s.register);
const unregister = useCommandStore((s) => s.unregister);
useEffect(() => {
const commands = [
{
id: 'project.create',
label: 'Create New Project',
description: 'Start a new project from scratch',
icon: <PlusIcon />,
shortcut: '⌘N',
keywords: ['new', 'add'],
perform: () => navigate('/projects/new'),
},
{
id: 'project.search',
label: 'Search Projects',
description: 'Find a project by name',
icon: <SearchIcon />,
shortcut: '⌘P',
keywords: ['find', 'open'],
perform: () => openProjectSearch(),
},
{
id: 'project.delete',
label: 'Delete Project',
description: 'Remove the current project',
icon: <TrashIcon />,
keywords: ['remove'],
perform: () => confirmDelete(),
enabled: () => !!currentProject,
},
];
commands.forEach(register);
return () => commands.forEach((c) => unregister(c.id));
}, [register, unregister, navigate]);
}
Building the Palette with cmdk
Don’t build the UI from scratch. The library cmdk (by Pacea) is the gold standard:
npm install cmdk// src/components/CommandPalette.tsx
import { Command } from 'cmdk';
import { useEffect, useState } from 'react';
import { useCommandStore } from '../commands/registry';
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const commands = useCommandStore((s) => s.search(query));
const execute = useCommandStore((s) => s.execute);
// Toggle with Cmd+K
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
const handleSelect = async (commandId: string) => {
setOpen(false);
setQuery('');
await execute(commandId);
};
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Command Palette"
className="command-dialog"
>
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Type a command or search..."
/>
<Command.List>
<Command.Empty>No commands found.</Command.Empty>
{commands.map((cmd) => (
<Command.Item
key={cmd.id}
value={cmd.id}
onSelect={() => handleSelect(cmd.id)}
>
<span className="command-icon">{cmd.icon}</span>
<div className="command-content">
<span className="command-label">{cmd.label}</span>
{cmd.description && (
<span className="command-description">{cmd.description}</span>
)}
</div>
{cmd.shortcut && (
<kbd className="command-shortcut">{cmd.shortcut}</kbd>
)}
</Command.Item>
))}
</Command.List>
</Command.Dialog>
);
}
Styling
/* src/styles/command-palette.css */
.command-dialog {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 640px;
background: var(--bg-elevated);
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid var(--border);
overflow: hidden;
}
[cmdk-input] {
width: 100%;
padding: 16px 20px;
font-size: 16px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
outline: none;
}
[cmdk-list] {
max-height: 400px;
overflow: auto;
padding: 8px;
}
[cmdk-item] {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
}
[cmdk-item][data-selected='true'] {
background: var(--accent);
color: white;
}
.command-shortcut {
margin-left: auto;
padding: 4px 8px;
background: var(--kbd-bg);
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}Global Keyboard Shortcuts
Cmd+K is just the entry point. Power users expect shortcuts like Cmd+S (Save) or Cmd+B (Toggle Sidebar).
useKeyboardShortcut Hook
// src/hooks/useKeyboardShortcut.ts
import { useEffect, useCallback } from 'react';
type Modifier = 'meta' | 'ctrl' | 'alt' | 'shift';
interface ShortcutOptions {
key: string;
modifiers?: Modifier[];
callback: () => void;
enabled?: boolean;
}
export function useKeyboardShortcut({
key,
modifiers = [],
callback,
enabled = true,
}: ShortcutOptions) {
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
const modifiersMatch =
modifiers.every((mod) => {
switch (mod) {
case 'meta': return event.metaKey;
case 'ctrl': return event.ctrlKey;
case 'alt': return event.altKey;
case 'shift': return event.shiftKey;
}
}) &&
// Ensure no extra modifiers
(modifiers.includes('meta') || !event.metaKey) &&
(modifiers.includes('ctrl') || !event.ctrlKey) &&
(modifiers.includes('alt') || !event.altKey) &&
(modifiers.includes('shift') || !event.shiftKey);
if (event.key.toLowerCase() === key.toLowerCase() && modifiersMatch) {
event.preventDefault();
callback();
}
},
[key, modifiers, callback, enabled]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
Usage
function App() {
const { save, toggleSidebar, undo, redo } = useActions();
useKeyboardShortcut({ key: 's', modifiers: ['meta'], callback: save });
useKeyboardShortcut({ key: 'b', modifiers: ['meta'], callback: toggleSidebar });
useKeyboardShortcut({ key: 'z', modifiers: ['meta'], callback: undo });
useKeyboardShortcut({ key: 'z', modifiers: ['meta', 'shift'], callback: redo });
return <main>...</main>;
}
Command Groups
Organize commands by category:
<Command.List>
<Command.Group heading="Projects">
<Command.Item>Create Project</Command.Item>
<Command.Item>Open Project</Command.Item>
</Command.Group>
<Command.Group heading="Settings">
<Command.Item>Toggle Dark Mode</Command.Item>
<Command.Item>Open Preferences</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item>View Documentation</Command.Item>
<Command.Item>Keyboard Shortcuts</Command.Item>
</Command.Group>
</Command.List>
Nested Commands
Sometimes you need sub-menus:
function CommandPalette() {
const [pages, setPages] = useState<string[]>([]);
const page = pages[pages.length - 1];
return (
<Command.Dialog>
<Command.Input />
<Command.List>
{!page && (
<>
<Command.Item onSelect={() => setPages([...pages, 'theme'])}>
Change Theme...
</Command.Item>
<Command.Item onSelect={() => setPages([...pages, 'language'])}>
Change Language...
</Command.Item>
</>
)}
{page === 'theme' && (
<>
<Command.Item onSelect={() => setTheme('light')}>Light</Command.Item>
<Command.Item onSelect={() => setTheme('dark')}>Dark</Command.Item>
<Command.Item onSelect={() => setTheme('system')}>System</Command.Item>
</>
)}
{page === 'language' && (
<>
<Command.Item onSelect={() => setLang('en')}>English</Command.Item>
<Command.Item onSelect={() => setLang('es')}>Español</Command.Item>
<Command.Item onSelect={() => setLang('fr')}>Français</Command.Item>
</>
)}
</Command.List>
{pages.length > 0 && (
<button onClick={() => setPages(pages.slice(0, -1))}>
← Back
</button>
)}
</Command.Dialog>
);
}
Keyboard Shortcuts Reference Panel
Show users all available shortcuts:
function KeyboardShortcutsPanel() {
const commands = useCommandStore((s) =>
Array.from(s.commands.values()).filter(c => c.shortcut)
);
return (
<div className="shortcuts-panel">
<h2>Keyboard Shortcuts</h2>
<table>
<tbody>
{commands.map((cmd) => (
<tr key={cmd.id}>
<td>{cmd.label}</td>
<td><kbd>{cmd.shortcut}</kbd></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
- Discoverability — Users can search for any action
- Keyboard-first — Power users fly through tasks
- Scriptable — Commands can be triggered programmatically
- Testable — Test commands in isolation
- Extensible — Plugins can register new commands
By decoupling input (Keyboard/Mouse) from actions (Commands), you make your app scriptable, testable, and incredibly fast to use.