Command Palette
Cmd+K modal with fuzzy search, keyboard navigation, grouped results, and spring animations
overlaysearchcommandkeyboardmodalnavigation
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface CommandItem {
id: string;
label: string;
icon?: React.ReactNode;
group?: string;
shortcut?: string;
onSelect?: () => void;
}
interface CommandPaletteProps {
items: CommandItem[];
placeholder?: string;
onSelect?: (item: CommandItem) => void;
trigger?: { key: string; metaKey?: boolean; ctrlKey?: boolean };
enableShortcut?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function CommandPalette({
items,
placeholder = "Type a command or search…",
onSelect,
trigger = { key: "k", metaKey: true },
enableShortcut = false,
open: controlledOpen,
onOpenChange,
}: CommandPaletteProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(value: boolean) => {
if (!isControlled) setInternalOpen(value);
onOpenChange?.(value);
},
[isControlled, onOpenChange]
);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filtered = items.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
);
const grouped = filtered.reduce<Record<string, CommandItem[]>>(
(acc, item) => {
const group = item.group || "Actions";
if (!acc[group]) acc[group] = [];
acc[group].push(item);
return acc;
},
{}
);
const flatFiltered = Object.values(grouped).flat();
const handleSelect = useCallback(
(item: CommandItem) => {
item.onSelect?.();
onSelect?.(item);
setOpen(false);
setQuery("");
},
[onSelect, setOpen]
);
useEffect(() => {
if (!enableShortcut) return;
const handleKeyDown = (e: KeyboardEvent) => {
const metaMatch = trigger.metaKey ? e.metaKey || e.ctrlKey : true;
const ctrlMatch = trigger.ctrlKey ? e.ctrlKey : true;
if (e.key === trigger.key && metaMatch && ctrlMatch) {
e.preventDefault();
setOpen(!open);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [trigger, enableShortcut, open, setOpen]);
useEffect(() => {
if (open) {
setQuery("");
setActiveIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
setActiveIndex(0);
}, [query]);
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, flatFiltered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && flatFiltered[activeIndex]) {
e.preventDefault();
handleSelect(flatFiltered[activeIndex]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
useEffect(() => {
if (!listRef.current) return;
const activeEl = listRef.current.querySelector(
`[data-index="${activeIndex}"]`
);
activeEl?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
let globalIndex = -1;
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="fixed left-1/2 top-[20%] z-50 w-full max-w-lg -translate-x-1/2 overflow-hidden rounded-xl border border-white/15 bg-gray-900 shadow-2xl"
>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
<svg
className="h-5 w-5 shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
className="flex-1 bg-transparent text-sm text-white placeholder-gray-500 outline-none"
/>
<kbd className="rounded bg-gray-800 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">
ESC
</kbd>
</div>
{/* Results */}
<div
ref={listRef}
className="max-h-72 overflow-y-auto overscroll-contain p-2"
>
{flatFiltered.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500">
No results found
</div>
) : (
Object.entries(grouped).map(([group, groupItems]) => (
<div key={group} className="mb-1">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
{group}
</div>
{groupItems.map((item) => {
globalIndex++;
const idx = globalIndex;
return (
<button
key={item.id}
data-index={idx}
onClick={() => handleSelect(item)}
onMouseEnter={() => setActiveIndex(idx)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors ${
activeIndex === idx
? "bg-white/10 text-white"
: "text-gray-300 hover:bg-white/5"
}`}
>
{item.icon && (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-white/5 text-gray-400">
{item.icon}
</span>
)}
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<kbd className="ml-auto rounded bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
{item.shortcut}
</kbd>
)}
</button>
);
})}
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-4 border-t border-white/10 px-4 py-2 text-[11px] text-gray-500">
<span className="flex items-center gap-1">
<kbd className="rounded bg-gray-800 px-1 py-0.5">↑↓</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="rounded bg-gray-800 px-1 py-0.5">↵</kbd>
Select
</span>
<span className="flex items-center gap-1">
<kbd className="rounded bg-gray-800 px-1 py-0.5">Esc</kbd>
Close
</span>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}