Components
Command Palette
A powerful, searchable command palette with fuzzy matching, keyboard navigation, and custom shortcuts.
Last updated on
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-reactCopy 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?