A powerful command palette with fuzzy search, keyboard navigation, and customizable actions. Perfect for creating command-driven interfaces.
npm i motion clsx tailwind-merge lucide-react
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
"use client"
import type React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Search, Hash, Settings, Moon, Sun, FileText, Zap, ArrowRight, Command } from "lucide-react"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
interface Action {
id: string
title: string
description?: string
icon: React.ReactNode
category: string
keywords: string[]
action: () => void
shortcut?: string
}
interface CommandPaletteProps {
actions?: Action[]
placeholder?: string
className?: string
}
export default function CommandPalette({
actions = defaultActions,
placeholder = "Type a command or search...",
className = "",
}: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const [theme, setTheme] = useState<"light" | "dark">("light")
// Keyboard shortcut to open command palette
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setOpen(true)
}
if (e.key === "Escape") {
setOpen(false)
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [])
// Handle navigation within the command palette
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, filteredActions.length - 1))
}
if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
}
if (e.key === "Enter") {
e.preventDefault()
if (filteredActions[selectedIndex]) {
filteredActions[selectedIndex].action()
setOpen(false)
setSearch("")
setSelectedIndex(0)
}
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [open, selectedIndex])
// Fuzzy search implementation
const fuzzySearch = useCallback((items: Action[], query: string) => {
if (!query) return items
const searchTerms = query.toLowerCase().split(" ").filter(Boolean)
return items
.map((item) => {
const searchableText = `${item.title} ${item.description} ${item.keywords.join(" ")}`.toLowerCase()
let score = 0
let matchedTerms = 0
searchTerms.forEach((term) => {
if (searchableText.includes(term)) {
matchedTerms++
// Boost score for title matches
if (item.title.toLowerCase().includes(term)) {
score += 10
}
// Boost score for exact matches
if (searchableText.includes(term)) {
score += 5
}
// Boost score for keyword matches
if (item.keywords.some((keyword) => keyword.toLowerCase().includes(term))) {
score += 3
}
}
})
// Only include items that match all search terms
if (matchedTerms === searchTerms.length) {
return { ...item, score }
}
return null
})
.filter(Boolean)
.sort((a, b) => (b?.score || 0) - (a?.score || 0)) as Action[]
}, [])
// Get actions with theme toggle functionality
const actionsWithTheme = useMemo(() => {
return actions.map((action) => {
if (action.id === "toggle-theme") {
return {
...action,
title: theme === "light" ? "Switch to Dark Mode" : "Switch to Light Mode",
icon: theme === "light" ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />,
action: () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"))
document.documentElement.classList.toggle("dark")
},
}
}
return action
})
}, [actions, theme])
const filteredActions = useMemo(() => {
return fuzzySearch(actionsWithTheme, search)
}, [actionsWithTheme, search, fuzzySearch])
// Reset selected index when search changes
useEffect(() => {
setSelectedIndex(0)
}, [search])
// Group actions by category
const groupedActions = useMemo(() => {
const groups: Record<string, Action[]> = {}
filteredActions.forEach((action) => {
if (!groups[action.category]) {
groups[action.category] = []
}
groups[action.category].push(action)
})
return groups
}, [filteredActions])
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
if (!newOpen) {
setSearch("")
setSelectedIndex(0)
}
}
return (
<>
{/* Trigger Button */}
<button
onClick={() => setOpen(true)}
className={`inline-flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-background border border-border rounded-lg hover:bg-muted/50 transition-colors ${className}`}
>
<Search className="w-4 h-4" />
<span>Search...</span>
<div className="flex items-center gap-1 ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<Command className="w-3 h-3" />K
</kbd>
</div>
</button>
{/* Command Palette Dialog */}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="p-0 max-w-2xl bg-background/95 backdrop-blur-sm border-border/50">
<div className="flex flex-col max-h-[80vh]">
{/* Search Input */}
<div className="flex items-center border-b border-border/50 px-4 py-3">
<Search className="w-4 h-4 text-muted-foreground mr-3" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder}
className="border-0 bg-transparent text-base placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
/>
</div>
{/* Results */}
<div className="overflow-y-auto max-h-[60vh] py-2">
{Object.keys(groupedActions).length === 0 ? (
<div className="px-4 py-8 text-center text-muted-foreground">
<div className="text-sm">No results found</div>
<div className="text-xs mt-1">Try searching for something else</div>
</div>
) : (
Object.entries(groupedActions).map(([category, categoryActions]) => (
<div key={category} className="mb-4 last:mb-0">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{category}
</div>
<div className="space-y-1 px-2">
{categoryActions.map((action) => {
const globalIndex = filteredActions.indexOf(action)
const isSelected = globalIndex === selectedIndex
return (
<button
key={action.id}
onClick={() => {
action.action()
setOpen(false)
setSearch("")
setSelectedIndex(0)
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
isSelected
? "bg-primary/10 text-primary border border-primary/20"
: "hover:bg-muted/50 text-foreground"
}`}
>
<div className="flex-shrink-0 text-muted-foreground">{action.icon}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{action.title}</div>
{action.description && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">
{action.description}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{action.shortcut && (
<Badge variant="secondary" className="text-xs font-mono">
{action.shortcut}
</Badge>
)}
<ArrowRight className="w-3 h-3 text-muted-foreground" />
</div>
</button>
)
})}
</div>
</div>
))
)}
</div>
{/* Footer */}
<div className="border-t border-border/50 px-4 py-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px]">↑↓</kbd>
<span>Navigate</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px]">↵</kbd>
<span>Select</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px]">esc</kbd>
<span>Close</span>
</div>
</div>
<div className="text-muted-foreground/60">{filteredActions.length} results</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
// Default actions for demonstration
const defaultActions: Action[] = [
{
id: "new-page",
title: "Create New Page",
description: "Add a new page to your workspace",
icon: <FileText className="w-4 h-4" />,
category: "Create",
keywords: ["new", "page", "create", "add"],
action: () => alert("Creating new page..."),
shortcut: "Ctrl+N",
},
{
id: "toggle-theme",
title: "Toggle Theme",
description: "Switch between light and dark mode",
icon: <Moon className="w-4 h-4" />,
category: "Appearance",
keywords: ["theme", "dark", "light", "mode"],
action: () => {}, // Will be overridden in component
shortcut: "Ctrl+Shift+T",
},
{
id: "open-settings",
title: "Open Settings",
description: "Configure your workspace preferences",
icon: <Settings className="w-4 h-4" />,
category: "Navigation",
keywords: ["settings", "preferences", "config"],
action: () => alert("Opening settings..."),
shortcut: "Ctrl+,",
},
{
id: "quick-search",
title: "Quick Search",
description: "Search across all your content",
icon: <Search className="w-4 h-4" />,
category: "Navigation",
keywords: ["search", "find", "lookup"],
action: () => alert("Opening search..."),
shortcut: "Ctrl+F",
},
{
id: "api-docs",
title: "API Documentation",
description: "View API reference and examples",
icon: <Hash className="w-4 h-4" />,
category: "Documentation",
keywords: ["api", "docs", "documentation", "reference"],
action: () => alert("Opening API docs..."),
},
{
id: "keyboard-shortcuts",
title: "Keyboard Shortcuts",
description: "View all available keyboard shortcuts",
icon: <Zap className="w-4 h-4" />,
category: "Documentation",
keywords: ["shortcuts", "hotkeys", "keyboard"],
action: () => alert("Showing shortcuts..."),
shortcut: "Ctrl+?",
},
{
id: "export-data",
title: "Export Data",
description: "Export your workspace data",
icon: <FileText className="w-4 h-4" />,
category: "Actions",
keywords: ["export", "download", "backup"],
action: () => alert("Exporting data..."),
},
]
Follow the installation steps above and start building with Command Palette.