Command Palette

Searchable ⌘K menu with groups, shortcuts and full keyboard control.

Click to open · use ↑ ↓ and Enter

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/command-palette.tsx

    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 &ldquo;{query}&rdquo;</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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.