Command Palette Patterns
Build keyboard-driven command palettes for quick navigation and actions using cmdk.
Overview#
Command palettes provide power-user navigation and search. This pattern covers:
- Basic command palette setup
- Search and filtering
- Grouped commands
- Recent items and history
- Custom styling
Prerequisites#
npm install cmdk lucide-reactBasic Command Palette#
A searchable command palette triggered by keyboard shortcut.
1// components/CommandPalette.tsx
2'use client'
3
4import { useState, useEffect } from 'react'
5import { Command } from 'cmdk'
6import { useRouter } from 'next/navigation'
7import { Search, Home, Settings, User, FileText, LogOut } from 'lucide-react'
8
9export function CommandPalette() {
10 const [open, setOpen] = useState(false)
11 const router = useRouter()
12
13 useEffect(() => {
14 function handleKeyDown(e: KeyboardEvent) {
15 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
16 e.preventDefault()
17 setOpen(o => !o)
18 }
19 }
20
21 document.addEventListener('keydown', handleKeyDown)
22 return () => document.removeEventListener('keydown', handleKeyDown)
23 }, [])
24
25 const runCommand = (command: () => void) => {
26 setOpen(false)
27 command()
28 }
29
30 return (
31 <Command.Dialog
32 open={open}
33 onOpenChange={setOpen}
34 label="Command Menu"
35 className="fixed inset-0 z-50"
36 >
37 {/* Backdrop */}
38 <div
39 className="fixed inset-0 bg-black/50"
40 onClick={() => setOpen(false)}
41 />
42
43 {/* Dialog */}
44 <div className="fixed left-1/2 top-1/4 w-full max-w-lg -translate-x-1/2 rounded-lg bg-white shadow-xl">
45 <div className="flex items-center border-b px-4">
46 <Search className="h-4 w-4 text-gray-400" />
47 <Command.Input
48 placeholder="Type a command or search..."
49 className="w-full border-0 bg-transparent p-4 outline-none placeholder:text-gray-400"
50 />
51 </div>
52
53 <Command.List className="max-h-80 overflow-auto p-2">
54 <Command.Empty className="p-4 text-center text-sm text-gray-500">
55 No results found.
56 </Command.Empty>
57
58 <Command.Group heading="Navigation" className="mb-2">
59 <Command.Item
60 onSelect={() => runCommand(() => router.push('/dashboard'))}
61 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-gray-100 aria-selected:bg-gray-100"
62 >
63 <Home className="h-4 w-4" />
64 Dashboard
65 </Command.Item>
66 <Command.Item
67 onSelect={() => runCommand(() => router.push('/settings'))}
68 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-gray-100 aria-selected:bg-gray-100"
69 >
70 <Settings className="h-4 w-4" />
71 Settings
72 </Command.Item>
73 <Command.Item
74 onSelect={() => runCommand(() => router.push('/profile'))}
75 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-gray-100 aria-selected:bg-gray-100"
76 >
77 <User className="h-4 w-4" />
78 Profile
79 </Command.Item>
80 </Command.Group>
81
82 <Command.Group heading="Actions" className="mb-2">
83 <Command.Item
84 onSelect={() => runCommand(() => console.log('Create new...'))}
85 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-gray-100 aria-selected:bg-gray-100"
86 >
87 <FileText className="h-4 w-4" />
88 Create New Document
89 </Command.Item>
90 <Command.Item
91 onSelect={() => runCommand(() => console.log('Sign out'))}
92 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm text-red-600 hover:bg-red-50 aria-selected:bg-red-50"
93 >
94 <LogOut className="h-4 w-4" />
95 Sign Out
96 </Command.Item>
97 </Command.Group>
98 </Command.List>
99
100 <div className="border-t px-4 py-2 text-xs text-gray-500">
101 <span className="mr-4">
102 <kbd className="rounded bg-gray-100 px-1">Enter</kbd> to select
103 </span>
104 <span className="mr-4">
105 <kbd className="rounded bg-gray-100 px-1">Esc</kbd> to close
106 </span>
107 </div>
108 </div>
109 </Command.Dialog>
110 )
111}Command Palette with Search#
Add async search functionality.
1// components/SearchCommandPalette.tsx
2'use client'
3
4import { useState, useEffect } from 'react'
5import { Command } from 'cmdk'
6import { Search, Loader2 } from 'lucide-react'
7import { useDebounce } from '@/hooks/useDebounce'
8
9interface SearchResult {
10 id: string
11 title: string
12 type: 'page' | 'document' | 'user'
13 url: string
14}
15
16export function SearchCommandPalette() {
17 const [open, setOpen] = useState(false)
18 const [query, setQuery] = useState('')
19 const [results, setResults] = useState<SearchResult[]>([])
20 const [loading, setLoading] = useState(false)
21
22 const debouncedQuery = useDebounce(query, 300)
23
24 useEffect(() => {
25 async function search() {
26 if (!debouncedQuery) {
27 setResults([])
28 return
29 }
30
31 setLoading(true)
32 try {
33 const res = await fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
34 const data = await res.json()
35 setResults(data.results)
36 } catch (error) {
37 console.error('Search failed:', error)
38 } finally {
39 setLoading(false)
40 }
41 }
42
43 search()
44 }, [debouncedQuery])
45
46 return (
47 <Command.Dialog open={open} onOpenChange={setOpen}>
48 <div className="fixed inset-0 bg-black/50" />
49 <div className="fixed left-1/2 top-1/4 w-full max-w-lg -translate-x-1/2 rounded-lg bg-white shadow-xl">
50 <div className="flex items-center border-b px-4">
51 {loading ? (
52 <Loader2 className="h-4 w-4 animate-spin text-gray-400" />
53 ) : (
54 <Search className="h-4 w-4 text-gray-400" />
55 )}
56 <Command.Input
57 value={query}
58 onValueChange={setQuery}
59 placeholder="Search everything..."
60 className="w-full border-0 bg-transparent p-4 outline-none"
61 />
62 </div>
63
64 <Command.List className="max-h-80 overflow-auto p-2">
65 <Command.Empty className="p-4 text-center text-sm text-gray-500">
66 {loading ? 'Searching...' : 'No results found.'}
67 </Command.Empty>
68
69 {results.length > 0 && (
70 <Command.Group heading="Results">
71 {results.map(result => (
72 <Command.Item
73 key={result.id}
74 value={result.title}
75 onSelect={() => {
76 window.location.href = result.url
77 setOpen(false)
78 }}
79 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-gray-100"
80 >
81 <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs uppercase">
82 {result.type}
83 </span>
84 {result.title}
85 </Command.Item>
86 ))}
87 </Command.Group>
88 )}
89 </Command.List>
90 </div>
91 </Command.Dialog>
92 )
93}Command Palette with Recent Items#
Track and display recently accessed items.
1// components/CommandPaletteWithHistory.tsx
2'use client'
3
4import { useState, useEffect } from 'react'
5import { Command } from 'cmdk'
6import { Clock, Search } from 'lucide-react'
7
8const STORAGE_KEY = 'command-palette-history'
9const MAX_RECENT = 5
10
11interface HistoryItem {
12 id: string
13 label: string
14 href: string
15 timestamp: number
16}
17
18export function CommandPaletteWithHistory() {
19 const [open, setOpen] = useState(false)
20 const [recentItems, setRecentItems] = useState<HistoryItem[]>([])
21
22 useEffect(() => {
23 const stored = localStorage.getItem(STORAGE_KEY)
24 if (stored) {
25 setRecentItems(JSON.parse(stored))
26 }
27 }, [open])
28
29 const addToHistory = (item: Omit<HistoryItem, 'timestamp'>) => {
30 const newHistory = [
31 { ...item, timestamp: Date.now() },
32 ...recentItems.filter(i => i.id !== item.id)
33 ].slice(0, MAX_RECENT)
34
35 setRecentItems(newHistory)
36 localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
37 }
38
39 const clearHistory = () => {
40 setRecentItems([])
41 localStorage.removeItem(STORAGE_KEY)
42 }
43
44 return (
45 <Command.Dialog open={open} onOpenChange={setOpen}>
46 <div className="fixed inset-0 bg-black/50" />
47 <div className="fixed left-1/2 top-1/4 w-full max-w-lg -translate-x-1/2 rounded-lg bg-white shadow-xl">
48 <div className="flex items-center border-b px-4">
49 <Search className="h-4 w-4 text-gray-400" />
50 <Command.Input
51 placeholder="Search..."
52 className="w-full border-0 bg-transparent p-4 outline-none"
53 />
54 </div>
55
56 <Command.List className="max-h-80 overflow-auto p-2">
57 <Command.Empty className="p-4 text-center text-sm text-gray-500">
58 No results found.
59 </Command.Empty>
60
61 {recentItems.length > 0 && (
62 <Command.Group heading="Recent">
63 {recentItems.map(item => (
64 <Command.Item
65 key={item.id}
66 onSelect={() => {
67 addToHistory(item)
68 window.location.href = item.href
69 setOpen(false)
70 }}
71 className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-gray-100"
72 >
73 <Clock className="h-4 w-4 text-gray-400" />
74 {item.label}
75 </Command.Item>
76 ))}
77 <button
78 onClick={clearHistory}
79 className="w-full px-3 py-2 text-left text-xs text-gray-500 hover:text-gray-700"
80 >
81 Clear recent
82 </button>
83 </Command.Group>
84 )}
85
86 <Command.Group heading="Quick Actions">
87 {/* Static commands */}
88 </Command.Group>
89 </Command.List>
90 </div>
91 </Command.Dialog>
92 )
93}Styled Command Palette#
A more polished command palette with custom styling.
1// components/StyledCommandPalette.tsx
2'use client'
3
4import { Command } from 'cmdk'
5import { Search, ArrowRight } from 'lucide-react'
6import { cn } from '@/lib/utils'
7
8export function StyledCommandPalette({
9 open,
10 onOpenChange
11}: {
12 open: boolean
13 onOpenChange: (open: boolean) => void
14}) {
15 return (
16 <Command.Dialog
17 open={open}
18 onOpenChange={onOpenChange}
19 className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
20 >
21 <div
22 className="fixed inset-0 bg-gray-900/50 backdrop-blur-sm"
23 onClick={() => onOpenChange(false)}
24 />
25
26 <div className="relative w-full max-w-xl overflow-hidden rounded-2xl bg-white shadow-2xl">
27 <div className="flex items-center gap-3 border-b px-4">
28 <Search className="h-5 w-5 text-gray-400" />
29 <Command.Input
30 placeholder="What do you need?"
31 className="h-14 w-full bg-transparent text-lg outline-none placeholder:text-gray-400"
32 />
33 <kbd className="hidden rounded border bg-gray-100 px-2 py-1 text-xs text-gray-500 sm:inline-block">
34 ESC
35 </kbd>
36 </div>
37
38 <Command.List className="max-h-[300px] overflow-auto p-2">
39 <Command.Empty className="py-8 text-center text-sm text-gray-500">
40 No results found. Try a different search term.
41 </Command.Empty>
42
43 <Command.Group>
44 {commands.map(command => (
45 <Command.Item
46 key={command.id}
47 value={command.label}
48 onSelect={command.action}
49 className={cn(
50 'group flex cursor-pointer items-center justify-between rounded-lg px-4 py-3',
51 'aria-selected:bg-blue-50'
52 )}
53 >
54 <div className="flex items-center gap-3">
55 <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 group-aria-selected:bg-blue-100">
56 {command.icon}
57 </div>
58 <div>
59 <p className="font-medium">{command.label}</p>
60 <p className="text-sm text-gray-500">{command.description}</p>
61 </div>
62 </div>
63 <ArrowRight className="h-4 w-4 text-gray-400 opacity-0 group-aria-selected:opacity-100" />
64 </Command.Item>
65 ))}
66 </Command.Group>
67 </Command.List>
68
69 <div className="flex items-center justify-between border-t px-4 py-2">
70 <div className="flex gap-4 text-xs text-gray-500">
71 <span>
72 <kbd className="rounded bg-gray-100 px-1">Tab</kbd> to navigate
73 </span>
74 <span>
75 <kbd className="rounded bg-gray-100 px-1">Enter</kbd> to select
76 </span>
77 </div>
78 <span className="text-xs text-gray-400">Powered by cmdk</span>
79 </div>
80 </div>
81 </Command.Dialog>
82 )
83}Best Practices#
- Use keyboard shortcuts - Cmd+K or Ctrl+K is the standard trigger
- Show keyboard hints - Help users learn navigation shortcuts
- Debounce search - Prevent excessive API calls while typing
- Group related commands - Organize by category for scannability
- Remember recent items - Help users quickly repeat actions
Related Patterns#
- Navigation - Navigation patterns
- Dropdowns - Dropdown menu patterns
- Modals - Modal dialog patterns