Command Palette
Searchable ⌘K menu with groups, shortcuts and full keyboard control.
Click to open · use ↑ ↓ and Enter
components/ui/command-palette.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { CornerDownLeft, FileText, LayoutDashboard, Moon, Plus, Search, Settings, UserPlus, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
export type Command = { id: string; label: string; group: string; icon: LucideIcon; shortcut?: string };
type CommandPaletteProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
commands: Command[];
onSelect: (command: Command) => void;
placeholder?: string;
};
export function CommandPalette({ open, onOpenChange, commands, onSelect, placeholder = "Type a command or search…" }: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [index, setIndex] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? commands.filter((c) => c.label.toLowerCase().includes(q) || c.group.toLowerCase().includes(q)) : commands;
}, [commands, query]);
useEffect(() => {
if (!open) {
setQuery("");
setIndex(0);
}
}, [open]);
useEffect(() => setIndex(0), [query]);
useEffect(() => {
listRef.current?.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" });
}, [index]);
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && filtered[index]) {
e.preventDefault();
onSelect(filtered[index]);
onOpenChange(false);
} else if (e.key === "Escape") {
onOpenChange(false);
}
}
let lastGroup = "";
if (!mounted) return null;
// Portal to <body> so the dialog is never trapped inside a transformed or blurred parent.
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[100] flex items-start justify-center p-4 pt-[15vh]">
<motion.div className="absolute inset-0 bg-zinc-900/30 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => onOpenChange(false)} />
<motion.div
role="dialog"
aria-modal="true"
aria-label="Command palette"
initial={{ opacity: 0, scale: 0.96, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: -8 }}
transition={{ type: "spring", stiffness: 420, damping: 32 }}
className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-2xl"
onKeyDown={onKeyDown}
>
<div className="flex items-center gap-3 border-b border-zinc-100 px-4">
<Search className="size-4 text-zinc-400" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
aria-label="Search commands"
className="h-12 flex-1 bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400"
/>
<kbd className="rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[10px] text-zinc-500">ESC</kbd>
</div>
<ul ref={listRef} role="listbox" className="max-h-80 overflow-y-auto p-2">
{filtered.length === 0 && <li className="px-3 py-8 text-center text-sm text-zinc-500">No results for “{query}”</li>}
{filtered.map((c, i) => {
const header = c.group !== lastGroup ? c.group : null;
lastGroup = c.group;
return (
<li key={c.id} role="option" aria-selected={i === index}>
{header && <p className="px-3 pb-1 pt-3 text-xs font-medium text-zinc-400">{header}</p>}
<button
data-index={i}
onMouseMove={() => setIndex(i)}
onClick={() => {
onSelect(c);
onOpenChange(false);
}}
className="relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-zinc-700"
>
{i === index && <motion.span layoutId="cmd-active" className="absolute inset-0 rounded-lg bg-zinc-100" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
<c.icon className="relative size-4 text-zinc-500" />
<span className="relative flex-1">{c.label}</span>
{c.shortcut && <kbd className="relative font-mono text-xs text-zinc-400">{c.shortcut}</kbd>}
{i === index && <CornerDownLeft className="relative size-3.5 text-zinc-400" />}
</button>
</li>
);
})}
</ul>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}
/** Opens the palette with ⌘K / Ctrl+K. */
export function useCommandK(setOpen: (fn: (open: boolean) => boolean) => void) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [setOpen]);
}
const demoCommands: Command[] = [
{ id: "dash", label: "Go to dashboard", group: "Navigation", icon: LayoutDashboard, shortcut: "G D" },
{ id: "docs", label: "Search documentation", group: "Navigation", icon: FileText },
{ id: "new", label: "Create new project", group: "Actions", icon: Plus, shortcut: "N" },
{ id: "invite", label: "Invite teammate", group: "Actions", icon: UserPlus },
{ id: "theme", label: "Toggle dark mode", group: "Preferences", icon: Moon },
{ id: "settings", label: "Open settings", group: "Preferences", icon: Settings, shortcut: "," },
];
export default function Demo() {
const [open, setOpen] = useState(false);
const [last, setLast] = useState<string | null>(null);
// In your app, call useCommandK(setOpen) to open the palette with ⌘K / Ctrl+K.
return (
<div className="text-center">
<button
onClick={() => setOpen(true)}
className="inline-flex w-72 items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-sm text-zinc-500 shadow-sm transition hover:border-zinc-300"
>
<Search className="size-4" />
<span className="flex-1 text-left">Search or run a command…</span>
<kbd className="rounded border border-zinc-200 bg-zinc-50 px-1.5 font-mono text-[10px]">⌘K</kbd>
</button>
<p className="mt-4 h-5 text-sm text-zinc-500">{last ? <>Ran: <span className="font-medium text-zinc-900">{last}</span></> : "Click to open · use ↑ ↓ and Enter"}</p>
<CommandPalette open={open} onOpenChange={setOpen} commands={demoCommands} onSelect={(c) => setLast(c.label)} />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/command-palette.tsxcomponents/ui/command-palette.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { CornerDownLeft, FileText, LayoutDashboard, Moon, Plus, Search, Settings, UserPlus, type LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; export type Command = { id: string; label: string; group: string; icon: LucideIcon; shortcut?: string }; type CommandPaletteProps = { open: boolean; onOpenChange: (open: boolean) => void; commands: Command[]; onSelect: (command: Command) => void; placeholder?: string; }; export function CommandPalette({ open, onOpenChange, commands, onSelect, placeholder = "Type a command or search…" }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); const listRef = useRef<HTMLUListElement>(null); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return q ? commands.filter((c) => c.label.toLowerCase().includes(q) || c.group.toLowerCase().includes(q)) : commands; }, [commands, query]); useEffect(() => { if (!open) { setQuery(""); setIndex(0); } }, [open]); useEffect(() => setIndex(0), [query]); useEffect(() => { listRef.current?.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" }); }, [index]); function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); setIndex((i) => Math.min(i + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setIndex((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter" && filtered[index]) { e.preventDefault(); onSelect(filtered[index]); onOpenChange(false); } else if (e.key === "Escape") { onOpenChange(false); } } let lastGroup = ""; if (!mounted) return null; // Portal to <body> so the dialog is never trapped inside a transformed or blurred parent. return createPortal( <AnimatePresence> {open && ( <div className="fixed inset-0 z-[100] flex items-start justify-center p-4 pt-[15vh]"> <motion.div className="absolute inset-0 bg-zinc-900/30 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => onOpenChange(false)} /> <motion.div role="dialog" aria-modal="true" aria-label="Command palette" initial={{ opacity: 0, scale: 0.96, y: -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: -8 }} transition={{ type: "spring", stiffness: 420, damping: 32 }} className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-2xl" onKeyDown={onKeyDown} > <div className="flex items-center gap-3 border-b border-zinc-100 px-4"> <Search className="size-4 text-zinc-400" /> <input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder={placeholder} aria-label="Search commands" className="h-12 flex-1 bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400" /> <kbd className="rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[10px] text-zinc-500">ESC</kbd> </div> <ul ref={listRef} role="listbox" className="max-h-80 overflow-y-auto p-2"> {filtered.length === 0 && <li className="px-3 py-8 text-center text-sm text-zinc-500">No results for “{query}”</li>} {filtered.map((c, i) => { const header = c.group !== lastGroup ? c.group : null; lastGroup = c.group; return ( <li key={c.id} role="option" aria-selected={i === index}> {header && <p className="px-3 pb-1 pt-3 text-xs font-medium text-zinc-400">{header}</p>} <button data-index={i} onMouseMove={() => setIndex(i)} onClick={() => { onSelect(c); onOpenChange(false); }} className="relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-zinc-700" > {i === index && <motion.span layoutId="cmd-active" className="absolute inset-0 rounded-lg bg-zinc-100" transition={{ type: "spring", stiffness: 500, damping: 40 }} />} <c.icon className="relative size-4 text-zinc-500" /> <span className="relative flex-1">{c.label}</span> {c.shortcut && <kbd className="relative font-mono text-xs text-zinc-400">{c.shortcut}</kbd>} {i === index && <CornerDownLeft className="relative size-3.5 text-zinc-400" />} </button> </li> ); })} </ul> </motion.div> </div> )} </AnimatePresence>, document.body, ); } /** Opens the palette with ⌘K / Ctrl+K. */ export function useCommandK(setOpen: (fn: (open: boolean) => boolean) => void) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((o) => !o); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [setOpen]); } const demoCommands: Command[] = [ { id: "dash", label: "Go to dashboard", group: "Navigation", icon: LayoutDashboard, shortcut: "G D" }, { id: "docs", label: "Search documentation", group: "Navigation", icon: FileText }, { id: "new", label: "Create new project", group: "Actions", icon: Plus, shortcut: "N" }, { id: "invite", label: "Invite teammate", group: "Actions", icon: UserPlus }, { id: "theme", label: "Toggle dark mode", group: "Preferences", icon: Moon }, { id: "settings", label: "Open settings", group: "Preferences", icon: Settings, shortcut: "," }, ]; export default function Demo() { const [open, setOpen] = useState(false); const [last, setLast] = useState<string | null>(null); // In your app, call useCommandK(setOpen) to open the palette with ⌘K / Ctrl+K. return ( <div className="text-center"> <button onClick={() => setOpen(true)} className="inline-flex w-72 items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-sm text-zinc-500 shadow-sm transition hover:border-zinc-300" > <Search className="size-4" /> <span className="flex-1 text-left">Search or run a command…</span> <kbd className="rounded border border-zinc-200 bg-zinc-50 px-1.5 font-mono text-[10px]">⌘K</kbd> </button> <p className="mt-4 h-5 text-sm text-zinc-500">{last ? <>Ran: <span className="font-medium text-zinc-900">{last}</span></> : "Click to open · use ↑ ↓ and Enter"}</p> <CommandPalette open={open} onOpenChange={setOpen} commands={demoCommands} onSelect={(c) => setLast(c.label)} /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.