Command Palette
A clean, keyboard-accessible command palette built with React, Tailwind CSS, and Framer Motion. Inspired by modern application interfaces, it provides a seamless way for users to search and execute actions across your platform.
Live Preview
Dependencies
npm install
lucide-reactframer-motionCode
"use client";
import { useEffect, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Home, Settings, LogOut, User, Search, Command } from "lucide-react";
type Command = {
id: string;
label: string;
group: string;
icon: React.ReactNode;
action: () => void;
};
const COMMANDS: Command[] = [
{
id: "home",
label: "Go to Dashboard",
group: "Navigation",
icon: <Home size={16} />,
action: () => alert("Dashboard"),
},
{
id: "profile",
label: "Open Profile",
group: "Navigation",
icon: <User size={16} />,
action: () => alert("Profile"),
},
{
id: "settings",
label: "Open Settings",
group: "Preferences",
icon: <Settings size={16} />,
action: () => alert("Settings"),
},
{
id: "logout",
label: "Logout",
group: "Account",
icon: <LogOut size={16} />,
action: () => alert("Logout"),
},
];
export default function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", down);
return () => window.removeEventListener("keydown", down);
}, []);
// Filter commands
const filtered = useMemo(() => {
return COMMANDS.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase()),
);
}, [query]);
// Group commands
const grouped = useMemo(() => {
const map: Record<string, Command[]> = {};
filtered.forEach((cmd) => {
if (!map[cmd.group]) map[cmd.group] = [];
map[cmd.group].push(cmd);
});
return map;
}, [filtered]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
}
if (e.key === "Enter") {
filtered[activeIndex]?.action();
setOpen(false);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [filtered, activeIndex, open]);
useEffect(() => {
setActiveIndex(0);
}, [query]);
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-center cursor-pointer gap-3 px-4 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm hover:border-zinc-300 dark:hover:border-zinc-700 transition-all text-sm text-zinc-700 dark:text-zinc-300 font-medium group"
>
<span>Search commands</span>
<kbd className="px-1.5 flex items-center gap-1 py-0.5 text-[13px] font-semibold text-zinc-400 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md">
<Command size={15} /> + K
</kbd>
</button>
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 bg-zinc-950/20 backdrop-blur-[6px] flex items-start justify-center pt-[120px] px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
initial={{ scale: 0.95, y: -10 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.95, y: -10 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="w-full max-w-[560px] bg-white dark:bg-zinc-900 border border-zinc-200/60 dark:border-zinc-800 rounded-2xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] overflow-hidden flex flex-col"
>
<div className="flex items-center gap-3 px-5 border-b border-zinc-100 dark:border-zinc-800">
<Search className="text-zinc-400 shrink-0" size={18} />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search commands or actions..."
className="w-full bg-transparent py-4 outline-none text-zinc-900 dark:text-zinc-50 placeholder:text-zinc-400 text-sm font-medium"
/>
</div>
<div className="p-3 max-h-[360px] overflow-y-auto flex flex-col gap-4">
{Object.entries(grouped).map(([group, cmds]) => (
<div key={group} className="flex flex-col gap-1">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider px-3 select-none">
{group}
</span>
{cmds.map((cmd) => {
const index = filtered.findIndex((f) => f.id === cmd.id);
const isActive = index === activeIndex;
return (
<motion.div
key={cmd.id}
onClick={() => {
cmd.action();
setOpen(false);
}}
className={`flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${
isActive
? "bg-zinc-100/80 dark:bg-zinc-800/80 text-zinc-900 dark:text-zinc-100 shadow-sm border-zinc-200/40 dark:border-zinc-700/50"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800/40"
}`}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<span
className={`shrink-0 ${
isActive
? "text-zinc-900 dark:text-zinc-100"
: "text-zinc-400"
}`}
>
{cmd.icon}
</span>
<span className="text-sm font-medium">
{cmd.label}
</span>
</div>
{isActive && (
<span className="text-xs text-zinc-400 font-medium px-1.5 py-0.5 rounded border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 shadow-sm">
Enter
</span>
)}
</motion.div>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-zinc-400 gap-2">
<p className="text-sm font-medium">No results found</p>
<p className="text-xs text-zinc-500">
Try adjusting your search terms
</p>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-100 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/40 flex items-center justify-between text-[11px] text-zinc-400 select-none">
<div>
Use <span className="font-bold">↑↓</span> to navigate,{" "}
<span className="font-bold">↵</span> to select
</div>
<div>ESC to close</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
Data Used
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | "home" | A unique identifier for the command. |
| label | string | "Go to Dashboard" | The display text for the command shown in the palette. |
| group | string | "Navigation" | The category under which the command is grouped. |
| icon | React.ReactNode | undefined | The icon component rendered alongside the label. |
| action | () => void | () => {} | The function executed when the command is selected. |