VibeSnip/

Command Palette

A powerful command palette with fuzzy search, keyboard navigation, and customizable actions. Perfect for creating command-driven interfaces.

navigationsearchkeyboardcommandsmodal
Navigation
Component Preview
Interactive preview of the Command Palette component
Install dependencies
Run this command to install all required dependencies
npm i motion clsx tailwind-merge lucide-react
Add util file
lib/utils.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
Copy the source code
components/ui/command-palette.tsx
"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..."),
  },
]

Ready to use this component?

Follow the installation steps above and start building with Command Palette.