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-react

Basic 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}

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#

  1. Use keyboard shortcuts - Cmd+K or Ctrl+K is the standard trigger
  2. Show keyboard hints - Help users learn navigation shortcuts
  3. Debounce search - Prevent excessive API calls while typing
  4. Group related commands - Organize by category for scannability
  5. Remember recent items - Help users quickly repeat actions