Components

Command Palette

A powerful, searchable command palette with fuzzy matching, keyboard navigation, and custom shortcuts.

Last updated on

Edit on GitHub

Basic Usage

The Command Palette is a versatile component that allows users to quickly access commands, search through content, and perform actions using their keyboard.

"use client";
 
import {
  Calculator,
  Calendar,
  CreditCard,
  Settings,
  Smile,
  User,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommandPalette } from "@/components/ui/command-palette";
 
export function CommandPaletteDemo() {
  const [open, setOpen] = useState(false);
 
  const groups = [
    {
      id: "suggestions",
      heading: "Suggestions",
      items: [
        {
          id: "calendar",
          label: "Calendar",
          icon: Calendar,
          onSelect: () => console.log("Calendar selected"),
        },
        {
          id: "search-emoji",
          label: "Search Emoji",
          icon: Smile,
          onSelect: () => console.log("Search Emoji selected"),
        },
        {
          id: "calculator",
          label: "Calculator",
          icon: Calculator,
          onSelect: () => console.log("Calculator selected"),
        },
      ],
    },
    {
      id: "settings",
      heading: "Settings",
      items: [
        {
          id: "profile",
          label: "Profile",
          icon: User,
          shortcut: ["⌘", "P"],
          onSelect: () => console.log("Profile selected"),
        },
        {
          id: "billing",
          label: "Billing",
          icon: CreditCard,
          shortcut: ["⌘", "B"],
          onSelect: () => console.log("Billing selected"),
        },
        {
          id: "settings",
          label: "Settings",
          icon: Settings,
          shortcut: ["⌘", "S"],
          onSelect: () => console.log("Settings selected"),
        },
      ],
    },
  ];
 
  return (
    <div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
      <p className="text-muted-foreground text-sm">
        Press{" "}
        <kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
          ⌘K
        </kbd>{" "}
        to open the command palette
      </p>
      <Button onClick={() => setOpen(true)}>Open Command Palette</Button>
 
      <CommandPalette open={open} onOpenChange={setOpen} groups={groups} />
    </div>
  );
}

Features

  • Fuzzy Search: Intelligent searching that matches characters even if they aren't consecutive.
  • Keyboard Navigation: Full support for arrow keys, Enter to select, and Escape to close.
  • Shortcuts: Support for global shortcuts (default ⌘K) and individual command shortcuts.
  • Groups: Organize commands into logical sections with headings.
  • Descriptions: Optional sub-text for commands to provide more context.
  • Icons: Support for Lucide icons for better visual recognition.

Customization

You can customize the placeholder, empty message, and the global shortcut used to toggle the palette.

"use client";
 
import {
  Bell,
  Github,
  HelpCircle,
  MessageSquare,
  Palette,
  Plus,
  Search,
  Settings,
  Shield,
  Twitter,
  User,
  Zap,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommandPalette } from "@/components/ui/command-palette";
 
export function CommandPaletteCustomDemo() {
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
 
  // Simulate loading state when searching
  const handleOpenChange = (isOpen: boolean) => {
    setOpen(isOpen);
    if (isOpen) {
      setLoading(true);
      setTimeout(() => setLoading(false), 800);
    }
  };
 
  const groups = [
    {
      id: "actions",
      heading: "Quick Actions",
      items: [
        {
          id: "new-file",
          label: "Create New File",
          description: "Create a new document in the current folder",
          icon: Plus,
          shortcut: ["⌘", "N"],
          onSelect: () => console.log("New file created"),
        },
        {
          id: "search-docs",
          label: "Search Documentation",
          description: "Find answers in our comprehensive docs",
          icon: Search,
          onSelect: () => console.log("Searching docs"),
        },
      ],
    },
    {
      id: "settings-nav",
      heading: "Settings & Configuration",
      items: [
        {
          id: "settings-menu",
          label: "System Settings",
          description: "Manage your account, security, and preferences",
          icon: Settings,
          children: [
            {
              id: "account-group",
              heading: "Account",
              items: [
                {
                  id: "profile",
                  label: "Edit Profile",
                  icon: User,
                  onSelect: () => console.log("Profile selected"),
                },
                {
                  id: "security",
                  label: "Security & Privacy",
                  icon: Shield,
                  onSelect: () => console.log("Security selected"),
                },
              ],
            },
            {
              id: "pref-group",
              heading: "Preferences",
              items: [
                {
                  id: "notifications",
                  label: "Notifications",
                  icon: Bell,
                  onSelect: () => console.log("Notifications selected"),
                },
                {
                  id: "appearance",
                  label: "Appearance",
                  icon: Palette,
                  onSelect: () => console.log("Appearance selected"),
                },
              ],
            },
          ],
        },
        {
          id: "theme-toggle",
          label: "Switch Theme",
          description: "Toggle between light and dark mode",
          icon: Zap,
          shortcut: ["⌘", "T"],
          onSelect: () => console.log("Theme toggled"),
        },
      ],
    },
    {
      id: "social",
      heading: "Social & Community",
      items: [
        {
          id: "github",
          label: "GitHub Repository",
          icon: Github,
          onSelect: () => window.open("https://github.com", "_blank"),
        },
        {
          id: "twitter",
          label: "Follow on Twitter",
          icon: Twitter,
          onSelect: () => window.open("https://twitter.com", "_blank"),
        },
      ],
    },
    {
      id: "help",
      heading: "Support",
      items: [
        {
          id: "help-center",
          label: "Help Center",
          icon: HelpCircle,
          onSelect: () => console.log("Opening help center"),
        },
        {
          id: "feedback",
          label: "Send Feedback",
          icon: MessageSquare,
          onSelect: () => console.log("Opening feedback"),
        },
      ],
    },
  ];
 
  return (
    <div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
      <div className="relative max-w-md space-y-4 overflow-hidden rounded-2xl border bg-card p-8 text-center shadow-sm">
        <div className="absolute top-0 left-0 h-1 w-full bg-primary/20">
          <div className="h-full w-1/3 animate-progress bg-primary" />
        </div>
 
        <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
          <Zap className="h-6 w-6 text-primary" />
        </div>
        <h3 className="font-semibold text-lg">Advanced Command Center</h3>
        <p className="text-muted-foreground text-sm">
          Try the new <strong>Sub-menus</strong>! Navigate to "System Settings"
          to see nested commands.
        </p>
        <div className="flex flex-col gap-2">
          <Button onClick={() => setOpen(true)} className="w-full">
            Open Command Palette
          </Button>
          <p className="text-[10px] text-muted-foreground">
            Supports Recent Items, Loading States, and Fuzzy Search
          </p>
        </div>
      </div>
 
      <CommandPalette
        open={open}
        onOpenChange={handleOpenChange}
        groups={groups}
        loading={loading}
        placeholder="Search actions, settings, social..."
      />
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/command-palette"

Manual

Install the following dependencies:

npm install lucide-react

Copy and paste the following code into your project. component/ui/command-palette.tsx

"use client";
 
import {
  ChevronRight,
  Clock,
  CornerDownLeft,
  Loader2,
  type LucideIcon,
  Search,
  X,
} from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
 
export interface CommandItem {
  id: string;
  label: string;
  description?: string;
  icon?: LucideIcon;
  shortcut?: string[];
  keywords?: string[];
  onSelect?: () => void;
  children?: CommandGroup[]; // Support for sub-menus
  disabled?: boolean;
}
 
export interface CommandGroup {
  id: string;
  heading: string;
  items: CommandItem[];
}
 
export interface CommandPaletteProps {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  groups: CommandGroup[];
  placeholder?: string;
  emptyMessage?: string;
  shortcut?: string[];
  loading?: boolean;
  showRecent?: boolean;
  maxRecent?: number;
}
 
interface FlattenedItem {
  type: "group" | "item";
  groupId: string;
  groupHeading?: string;
  item?: CommandItem;
  score?: number;
  matches?: [number, number][];
}
 
interface PageState {
  id: string;
  title: string;
  groups: CommandGroup[];
}
 
export function CommandPalette({
  open: controlledOpen,
  onOpenChange,
  groups: initialGroups,
  placeholder = "Type a command or search...",
  emptyMessage = "No results found.",
  shortcut = ["⌘", "K"],
  loading = false,
  showRecent = true,
  maxRecent = 5,
}: CommandPaletteProps) {
  const [internalOpen, setInternalOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [pages, setPages] = useState<PageState[]>([
    { id: "root", title: "Root", groups: initialGroups },
  ]);
  const [recentItems, setRecentItems] = useState<CommandItem[]>([]);
 
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());
 
  const isOpen = controlledOpen ?? internalOpen;
  const setOpen = onOpenChange ?? setInternalOpen;
  const currentPage = pages[pages.length - 1] || pages[0];
 
  // Load recent items from localStorage
  useEffect(() => {
    if (typeof window !== "undefined") {
      const saved = localStorage.getItem("jolyui-command-recent");
      if (saved) {
        try {
          setRecentItems(JSON.parse(saved));
        } catch (e) {
          console.error("Failed to load recent items", e);
        }
      }
    }
  }, []);
 
  const saveRecent = useCallback(
    (item: CommandItem) => {
      setRecentItems((prev) => {
        const filtered = prev.filter((i) => i.id !== item.id);
        const updated = [item, ...filtered].slice(0, maxRecent);
        localStorage.setItem("jolyui-command-recent", JSON.stringify(updated));
        return updated;
      });
    },
    [maxRecent],
  );
 
  // Flatten and filter items based on search query
  const flattenedItems = useMemo(() => {
    const items: FlattenedItem[] = [];
    const currentGroups = currentPage?.groups || [];
 
    // Add Recent group if on root page and query is empty
    if (
      pages.length === 1 &&
      query === "" &&
      showRecent &&
      recentItems.length > 0
    ) {
      items.push({ type: "group", groupId: "recent", groupHeading: "Recent" });
      recentItems.forEach((item) => {
        items.push({ type: "item", groupId: "recent", item });
      });
    }
 
    currentGroups.forEach((group) => {
      const matchedItems: FlattenedItem[] = [];
 
      group.items.forEach((item) => {
        if (item.disabled) return;
 
        const searchText = [item.label, ...(item.keywords || [])].join(" ");
        const match = fuzzySearch(query || "", searchText);
 
        if (match) {
          const labelMatch = fuzzySearch(query || "", item.label);
          matchedItems.push({
            type: "item",
            groupId: group.id,
            item,
            score: match.score,
            matches: labelMatch?.matches || [],
          });
        }
      });
 
      matchedItems.sort((a, b) => (b.score || 0) - (a.score || 0));
 
      if (matchedItems.length > 0) {
        items.push({
          type: "group",
          groupId: group.id,
          groupHeading: group.heading,
        });
        items.push(...matchedItems);
      }
    });
 
    return items;
  }, [currentPage?.groups, query, pages.length, showRecent, recentItems]);
 
  const selectableItems = useMemo(
    () => flattenedItems.filter((item) => item.type === "item"),
    [flattenedItems],
  );
 
  // Reset selection when query or page changes
  useEffect(() => {
    setSelectedIndex(0);
  }, []);
 
  // Keyboard shortcut to open
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === "k") {
        e.preventDefault();
        setOpen(!isOpen);
      }
    };
 
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen, setOpen]);
 
  // Focus input when opened
  useEffect(() => {
    if (isOpen) {
      setQuery("");
      setSelectedIndex(0);
      setPages([{ id: "root", title: "Root", groups: initialGroups }]);
      setTimeout(() => inputRef.current?.focus(), 0);
    }
  }, [isOpen, initialGroups]);
 
  // Scroll selected item into view
  useEffect(() => {
    const selectedItem = itemRefs.current.get(selectedIndex);
    if (selectedItem && listRef.current) {
      selectedItem.scrollIntoView({ block: "nearest" });
    }
  }, [selectedIndex]);
 
  const handleSelect = useCallback(
    (item: CommandItem) => {
      if (item.children) {
        setPages((prev) => [
          ...prev,
          {
            id: item.id,
            title: item.label,
            groups: item.children || [],
          },
        ]);
        setQuery("");
        return;
      }
 
      if (item.onSelect) {
        item.onSelect();
        saveRecent(item);
        setOpen(false);
      }
    },
    [saveRecent, setOpen],
  );
 
  const handleBack = useCallback(() => {
    if (pages.length > 1) {
      setPages((prev) => prev.slice(0, -1));
      setQuery("");
    }
  }, [pages.length]);
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          setSelectedIndex((i) => (i < selectableItems.length - 1 ? i + 1 : 0));
          break;
        case "ArrowUp":
          e.preventDefault();
          setSelectedIndex((i) => (i > 0 ? i - 1 : selectableItems.length - 1));
          break;
        case "Enter": {
          e.preventDefault();
          const selected = selectableItems[selectedIndex]?.item;
          if (selected) {
            handleSelect(selected);
          }
          break;
        }
        case "Backspace":
          if (query === "" && pages.length > 1) {
            e.preventDefault();
            handleBack();
          }
          break;
        case "Escape":
          e.preventDefault();
          if (pages.length > 1) {
            handleBack();
          } else {
            setOpen(false);
          }
          break;
      }
    },
    [
      selectableItems,
      selectedIndex,
      setOpen,
      handleSelect,
      handleBack,
      query,
      pages.length,
    ],
  );
 
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
            onClick={() => setOpen(false)}
            aria-hidden="true"
          />
 
          <div
            role="dialog"
            aria-modal="true"
            className="-translate-x-1/2 fixed top-[20%] left-1/2 z-50 w-full max-w-xl"
          >
            <motion.div
              initial={{ opacity: 0, scale: 0.95, y: 10 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.95, y: 10 }}
              transition={{ duration: 0.2, ease: "easeOut" }}
              className="flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
            >
              {/* Header / Search */}
              <div className="relative flex items-center gap-3 border-border border-b px-4">
                {pages.length > 1 ? (
                  <button
                    onClick={handleBack}
                    className="rounded-md p-1 transition-colors hover:bg-muted"
                  >
                    <X className="h-4 w-4 rotate-90 text-muted-foreground" />
                  </button>
                ) : (
                  <Search className="h-4 w-4 shrink-0 text-muted-foreground" />
                )}
 
                <div className="flex min-w-0 flex-1 items-center">
                  {pages.length > 1 && (
                    <div className="mr-2 flex shrink-0 items-center gap-1">
                      <span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary text-xs">
                        {currentPage?.title || ""}
                      </span>
                      <span className="text-muted-foreground">/</span>
                    </div>
                  )}
                  <input
                    ref={inputRef}
                    type="text"
                    value={query}
                    onChange={(e) => setQuery(e.target.value)}
                    onKeyDown={handleKeyDown}
                    placeholder={
                      pages.length > 1
                        ? `Search in ${currentPage?.title || ""}...`
                        : placeholder
                    }
                    className="h-12 flex-1 bg-transparent text-foreground text-sm outline-none placeholder:text-muted-foreground"
                  />
                </div>
 
                {loading && (
                  <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
                )}
 
                <div className="ml-2 flex items-center gap-1">
                  {shortcut.map((key, i) => (
                    <kbd
                      key={i}
                      className="hidden h-5 min-w-[20px] items-center justify-center rounded bg-muted px-1.5 font-mono text-[10px] text-muted-foreground sm:flex"
                    >
                      {key}
                    </kbd>
                  ))}
                </div>
              </div>
 
              {/* Results */}
              <div
                ref={listRef}
                className="max-h-[380px] overflow-y-auto scroll-smooth py-2"
              >
                <LayoutGroup id="command-list">
                  {flattenedItems.length === 0 ? (
                    <motion.div
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      className="space-y-2 py-12 text-center"
                    >
                      <Search className="mx-auto h-8 w-8 text-muted-foreground opacity-20" />
                      <p className="text-muted-foreground text-sm">
                        {emptyMessage}
                      </p>
                    </motion.div>
                  ) : (
                    flattenedItems.map((flatItem) => {
                      if (flatItem.type === "group") {
                        return (
                          <div
                            key={`group-${flatItem.groupId}`}
                            className="mt-2 px-3 py-2 font-bold text-[10px] text-muted-foreground/70 uppercase tracking-widest first:mt-0"
                          >
                            {flatItem.groupHeading}
                          </div>
                        );
                      }
 
                      const currentItemIndex = selectableItems.findIndex(
                        (si) => si.item?.id === flatItem.item?.id,
                      );
                      const isSelected = currentItemIndex === selectedIndex;
                      const item = flatItem.item;
                      if (!item) return null;
                      const Icon = item.icon;
                      const highlightedLabel = highlightMatches(
                        item.label,
                        flatItem.matches || [],
                      );
 
                      return (
                        <motion.div
                          layout
                          key={item.id}
                          ref={(el: HTMLDivElement | null) => {
                            if (el) itemRefs.current.set(currentItemIndex, el);
                          }}
                          className={cn(
                            "group relative mx-2 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition-all duration-150",
                            isSelected &&
                              "bg-accent text-accent-foreground shadow-sm",
                          )}
                          onClick={() => handleSelect(item)}
                          onMouseEnter={() =>
                            setSelectedIndex(currentItemIndex)
                          }
                        >
                          {isSelected && (
                            <motion.div
                              layoutId="active-pill"
                              className="-z-10 absolute inset-0 rounded-lg bg-accent"
                              transition={{
                                type: "spring",
                                bounce: 0.2,
                                duration: 0.4,
                              }}
                            />
                          )}
 
                          <div
                            className={cn(
                              "flex h-8 w-8 items-center justify-center rounded-md border transition-colors",
                              isSelected
                                ? "border-primary/20 bg-background"
                                : "border-transparent bg-muted/50",
                            )}
                          >
                            {flatItem.groupId === "recent" ? (
                              <Clock className="h-4 w-4 text-muted-foreground" />
                            ) : Icon ? (
                              <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
                            ) : (
                              <div className="h-1 w-1 rounded-full bg-muted-foreground" />
                            )}
                          </div>
 
                          <div className="min-w-0 flex-1">
                            <div className="truncate font-medium text-foreground text-sm">
                              {highlightedLabel.map((part, i) => (
                                <span
                                  key={i}
                                  className={
                                    part.highlighted
                                      ? "font-semibold text-primary"
                                      : undefined
                                  }
                                >
                                  {part.text}
                                </span>
                              ))}
                            </div>
                            {item.description && (
                              <div className="mt-0.5 truncate text-muted-foreground text-xs">
                                {item.description}
                              </div>
                            )}
                          </div>
 
                          <div className="flex shrink-0 items-center gap-2">
                            {item.children && (
                              <ChevronRight className="h-4 w-4 text-muted-foreground opacity-50" />
                            )}
                            {item.shortcut && !item.children && (
                              <div className="flex items-center gap-1">
                                {item.shortcut.map((key, i) => (
                                  <kbd
                                    key={i}
                                    className="flex h-4 min-w-[18px] items-center justify-center rounded border border-border/50 bg-muted px-1 font-mono text-[9px] text-muted-foreground"
                                  >
                                    {key}
                                  </kbd>
                                ))}
                              </div>
                            )}
                          </div>
                        </motion.div>
                      );
                    })
                  )}
                </LayoutGroup>
              </div>
 
              {/* Footer */}
              <div className="flex items-center justify-between border-border border-t bg-muted/20 px-4 py-3 text-[10px] text-muted-foreground">
                <div className="flex items-center gap-4">
                  <span className="flex items-center gap-1.5">
                    <kbd className="rounded border border-border/50 bg-muted px-1 py-0.5 font-mono">
                      ↑↓
                    </kbd>
                    navigate
                  </span>
                  <span className="flex items-center gap-1.5">
                    <kbd className="rounded border border-border/50 bg-muted px-1 py-0.5 font-mono">
                      <CornerDownLeft className="h-2 w-2" />
                    </kbd>
                    select
                  </span>
                  {pages.length > 1 && (
                    <span className="flex items-center gap-1.5">
                      <kbd className="rounded border border-border/50 bg-muted px-1 py-0.5 font-mono">
                        esc
                      </kbd>
                      back
                    </span>
                  )}
                </div>
                <div className="flex items-center gap-2">
                  <span className="opacity-50">JolyUI Command</span>
                </div>
              </div>
            </motion.div>
          </div>
        </>
      )}
    </AnimatePresence>
  );
}
 
export interface FuzzyMatch {
  item: string;
  score: number;
  matches: [number, number][];
}
 
export function fuzzySearch(query: string, text: string): FuzzyMatch | null {
  if (!query) return { item: text, score: 1, matches: [] };
 
  const queryLower = query.toLowerCase();
  const textLower = text.toLowerCase();
 
  let queryIndex = 0;
  let score = 0;
  const matches: [number, number][] = [];
  let currentMatchStart = -1;
  let consecutiveMatches = 0;
 
  for (let i = 0; i < text.length && queryIndex < query.length; i++) {
    if (textLower[i] === queryLower[queryIndex]) {
      if (currentMatchStart === -1) currentMatchStart = i;
      consecutiveMatches++;
      queryIndex++;
      score += 1 + consecutiveMatches * 0.5;
      if (i === 0) score += 2;
      const prevChar = i > 0 ? text[i - 1] : "";
      if (prevChar && /[\s\-_]/.test(prevChar)) score += 1.5;
    } else {
      if (currentMatchStart !== -1) {
        matches.push([currentMatchStart, i]);
        currentMatchStart = -1;
        consecutiveMatches = 0;
      }
    }
  }
 
  if (currentMatchStart !== -1) matches.push([currentMatchStart, text.length]);
  if (queryIndex < query.length) return null;
 
  return { item: text, score: score / query.length, matches };
}
 
export function highlightMatches(
  text: string,
  matches: [number, number][],
): { text: string; highlighted: boolean }[] {
  if (matches.length === 0) return [{ text, highlighted: false }];
 
  const result: { text: string; highlighted: boolean }[] = [];
  let lastIndex = 0;
 
  for (const [start, end] of matches) {
    if (start > lastIndex) {
      result.push({ text: text.slice(lastIndex, start), highlighted: false });
    }
    result.push({ text: text.slice(start, end), highlighted: true });
    lastIndex = end;
  }
 
  if (lastIndex < text.length) {
    result.push({ text: text.slice(lastIndex), highlighted: false });
  }
 
  return result;
}

API Reference

Prop

Type

CommandGroup

Prop

Type

CommandItem

Prop

Type

How is this guide?

On this page