Skip to content
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

"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.