All Taste components
Navigation
Command menu
A search palette that finds records, runs actions and jumps to pages from the keyboard.
grouped results · highlighted match · shortcuts · keyboard hints
Esc
Customers
Kaveri Foods
Kavya Textiles
Invoices
INV-1042 · Kaveri Foods
Actions
Create invoice
Send payment reminder
↑↓Move↵Open⌘KToggle
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { FileText, Plus, Search, Users, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type Command = { id: string; group: string; label: string; icon: LucideIcon; meta?: string; run: () => void };
/** Opens with Cmd/Ctrl+K. A native <dialog> gives focus handling and Escape-to-close. */
export function CommandMenu({ commands }: { commands: Command[] }) {
const ref = useRef<HTMLDialogElement>(null);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
const d = ref.current;
if (d?.open) d.close();
else d?.showModal();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const results = useMemo(
() => commands.filter((c) => c.label.toLowerCase().includes(query.trim().toLowerCase())),
[commands, query],
);
const groups = [...new Set(results.map((r) => r.group))];
const run = (c?: Command) => {
if (!c) return;
c.run();
ref.current?.close();
};
return (
<dialog
ref={ref}
aria-label="Command menu"
onClose={() => { setQuery(""); setActive(0); }}
className="mt-[12vh] w-full max-w-[480px] overflow-hidden rounded-lg border border-border bg-bg p-0 text-fg shadow-2xl backdrop:bg-neutral-950/40"
>
<div className="flex h-12 items-center gap-2.5 border-b border-border px-3.5 text-fg-subtle">
<Search className="size-[18px]" aria-hidden />
<input
autoFocus
role="combobox"
aria-expanded="true"
aria-controls="command-results"
aria-activedescendant={results[active] ? "cmd-" + results[active].id : undefined}
placeholder="Search customers, invoices, actions…"
value={query}
onChange={(e) => { setQuery(e.target.value); setActive(0); }}
onKeyDown={(e) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, results.length - 1)); }
if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
if (e.key === "Enter") run(results[active]);
}}
className="min-w-0 flex-1 bg-transparent text-[14.5px] text-fg outline-none placeholder:text-fg-subtle"
/>
<kbd className="rounded-sm border border-b-2 border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[11px] text-fg-muted">Esc</kbd>
</div>
<div id="command-results" role="listbox" aria-label="Results" className="max-h-[300px] overflow-auto px-1.5 pb-1.5 pt-1">
{results.length === 0 && <p className="px-2 py-6 text-center text-sm text-fg-muted">No results for “{query}”</p>}
{groups.map((group) => (
<div key={group} role="presentation">
<p className="px-2 pb-1 pt-2 text-[11.5px] font-medium text-fg-subtle">{group}</p>
{results.filter((r) => r.group === group).map((c) => {
const i = results.indexOf(c);
return (
<div
key={c.id}
id={"cmd-" + c.id}
role="option"
aria-selected={i === active}
onMouseMove={() => setActive(i)}
onClick={() => run(c)}
className={cn("flex h-9 cursor-pointer items-center gap-2.5 rounded px-2 text-[13.5px]", i === active && "bg-surface-2")}
>
<c.icon className="size-4 shrink-0 text-fg-muted" aria-hidden />
<span className="truncate">{c.label}</span>
{c.meta && <span className="ml-auto shrink-0 text-[12.5px] tabular-nums text-fg-subtle">{c.meta}</span>}
</div>
);
})}
</div>
))}
</div>
</dialog>
);
}
// Example commands:
// [{ id: "kaveri", group: "Customers", label: "Kaveri Foods", icon: Users, meta: "14 invoices", run: () => router.push("/customers/kaveri") },
// { id: "inv-1042", group: "Invoices", label: "INV-1042 · Kaveri Foods", icon: FileText, meta: "₹48,500", run: ... },
// { id: "new", group: "Actions", label: "Create invoice", icon: Plus, run: ... }]
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.