Command Palette

Featured

by 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>
  );
}