Command Palette
Featuredby Dax · tsx · 84 lines
A ⌘K command palette with fuzzy search, keyboard navigation, and grouped results. Opens with Cmd+K, navigates with arrows, runs with Enter. The pattern behind every modern app launcher.
commandpalettesearchkeyboard
84 lines
"use client";
import { useState, useEffect, useRef } from "react";
import { Search } from "lucide-react";
type Command = { id: string; label: string; group: string; action: () => void };
export function CommandPalette({ commands }: { commands: Command[] }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(o => !o);
setQuery("");
setActive(0);
}
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
const filtered = commands.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase())
);
const groups = [...new Set(filtered.map(c => c.group))];
function run(cmd: Command) { cmd.action(); setOpen(false); }
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)} />
<div className="relative w-full max-w-lg rounded-2xl border border-neutral-200 bg-white shadow-2xl overflow-hidden">
<div className="flex items-center gap-3 border-b border-neutral-100 px-4 py-3">
<Search size={16} className="text-neutral-400" />
<input
ref={inputRef}
value={query}
onChange={e => { setQuery(e.target.value); setActive(0); }}
onKeyDown={e => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive(a => Math.min(a + 1, filtered.length - 1)); }
if (e.key === "ArrowUp") { e.preventDefault(); setActive(a => Math.max(a - 1, 0)); }
if (e.key === "Enter" && filtered[active]) run(filtered[active]);
}}
placeholder="Type a command..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
<kbd className="rounded bg-neutral-100 px-1.5 py-0.5 text-[10px] font-mono text-neutral-400">esc</kbd>
</div>
<div className="max-h-72 overflow-y-auto p-2">
{groups.map(group => (
<div key={group}>
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-neutral-400">{group}</div>
{filtered.filter(c => c.group === group).map(cmd => {
const idx = filtered.indexOf(cmd);
return (
<button
key={cmd.id}
onClick={() => run(cmd)}
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition ${idx === active ? "bg-neutral-100 text-neutral-900" : "text-neutral-600 hover:bg-neutral-50"}`}
>
{cmd.label}
</button>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="py-8 text-center text-sm text-neutral-400">No results.</div>
)}
</div>
</div>
</div>
);
}