Command Palette

Cmd+K modal with fuzzy search, keyboard navigation, grouped results, and spring animations

overlaysearchcommandkeyboardmodalnavigation
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";

interface CommandItem {
  id: string;
  label: string;
  icon?: React.ReactNode;
  group?: string;
  shortcut?: string;
  onSelect?: () => void;
}

interface CommandPaletteProps {
  items: CommandItem[];
  placeholder?: string;
  onSelect?: (item: CommandItem) => void;
  trigger?: { key: string; metaKey?: boolean; ctrlKey?: boolean };
  enableShortcut?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

export default function CommandPalette({
  items,
  placeholder = "Type a command or search…",
  onSelect,
  trigger = { key: "k", metaKey: true },
  enableShortcut = false,
  open: controlledOpen,
  onOpenChange,
}: CommandPaletteProps) {
  const [internalOpen, setInternalOpen] = useState(false);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : internalOpen;
  const setOpen = useCallback(
    (value: boolean) => {
      if (!isControlled) setInternalOpen(value);
      onOpenChange?.(value);
    },
    [isControlled, onOpenChange]
  );

  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);

  const filtered = items.filter((item) =>
    item.label.toLowerCase().includes(query.toLowerCase())
  );

  const grouped = filtered.reduce<Record<string, CommandItem[]>>(
    (acc, item) => {
      const group = item.group || "Actions";
      if (!acc[group]) acc[group] = [];
      acc[group].push(item);
      return acc;
    },
    {}
  );

  const flatFiltered = Object.values(grouped).flat();

  const handleSelect = useCallback(
    (item: CommandItem) => {
      item.onSelect?.();
      onSelect?.(item);
      setOpen(false);
      setQuery("");
    },
    [onSelect, setOpen]
  );

  useEffect(() => {
    if (!enableShortcut) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      const metaMatch = trigger.metaKey ? e.metaKey || e.ctrlKey : true;
      const ctrlMatch = trigger.ctrlKey ? e.ctrlKey : true;

      if (e.key === trigger.key && metaMatch && ctrlMatch) {
        e.preventDefault();
        setOpen(!open);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [trigger, enableShortcut, open, setOpen]);

  useEffect(() => {
    if (open) {
      setQuery("");
      setActiveIndex(0);
      setTimeout(() => inputRef.current?.focus(), 50);
    }
  }, [open]);

  useEffect(() => {
    setActiveIndex(0);
  }, [query]);

  const handleInputKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActiveIndex((prev) => Math.min(prev + 1, flatFiltered.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActiveIndex((prev) => Math.max(prev - 1, 0));
    } else if (e.key === "Enter" && flatFiltered[activeIndex]) {
      e.preventDefault();
      handleSelect(flatFiltered[activeIndex]);
    } else if (e.key === "Escape") {
      setOpen(false);
    }
  };

  useEffect(() => {
    if (!listRef.current) return;
    const activeEl = listRef.current.querySelector(
      `[data-index="${activeIndex}"]`
    );
    activeEl?.scrollIntoView({ block: "nearest" });
  }, [activeIndex]);

  let globalIndex = -1;

  return (
    <AnimatePresence>
      {open && (
        <>
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.15 }}
            className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
            onClick={() => setOpen(false)}
          />
          <motion.div
            initial={{ opacity: 0, scale: 0.95, y: -20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.95, y: -20 }}
            transition={{ type: "spring", stiffness: 400, damping: 30 }}
            className="fixed left-1/2 top-[20%] z-50 w-full max-w-lg -translate-x-1/2 overflow-hidden rounded-xl border border-white/15 bg-gray-900 shadow-2xl"
          >
            {/* Search input */}
            <div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
              <svg
                className="h-5 w-5 shrink-0 text-gray-400"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
                strokeWidth={2}
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
                />
              </svg>
              <input
                ref={inputRef}
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                onKeyDown={handleInputKeyDown}
                placeholder={placeholder}
                className="flex-1 bg-transparent text-sm text-white placeholder-gray-500 outline-none"
              />
              <kbd className="rounded bg-gray-800 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">
                ESC
              </kbd>
            </div>

            {/* Results */}
            <div
              ref={listRef}
              className="max-h-72 overflow-y-auto overscroll-contain p-2"
            >
              {flatFiltered.length === 0 ? (
                <div className="px-3 py-8 text-center text-sm text-gray-500">
                  No results found
                </div>
              ) : (
                Object.entries(grouped).map(([group, groupItems]) => (
                  <div key={group} className="mb-1">
                    <div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
                      {group}
                    </div>
                    {groupItems.map((item) => {
                      globalIndex++;
                      const idx = globalIndex;
                      return (
                        <button
                          key={item.id}
                          data-index={idx}
                          onClick={() => handleSelect(item)}
                          onMouseEnter={() => setActiveIndex(idx)}
                          className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors ${
                            activeIndex === idx
                              ? "bg-white/10 text-white"
                              : "text-gray-300 hover:bg-white/5"
                          }`}
                        >
                          {item.icon && (
                            <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-white/5 text-gray-400">
                              {item.icon}
                            </span>
                          )}
                          <span className="flex-1">{item.label}</span>
                          {item.shortcut && (
                            <kbd className="ml-auto rounded bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
                              {item.shortcut}
                            </kbd>
                          )}
                        </button>
                      );
                    })}
                  </div>
                ))
              )}
            </div>

            {/* Footer */}
            <div className="flex items-center gap-4 border-t border-white/10 px-4 py-2 text-[11px] text-gray-500">
              <span className="flex items-center gap-1">
                <kbd className="rounded bg-gray-800 px-1 py-0.5">↑↓</kbd>
                Navigate
              </span>
              <span className="flex items-center gap-1">
                <kbd className="rounded bg-gray-800 px-1 py-0.5"></kbd>
                Select
              </span>
              <span className="flex items-center gap-1">
                <kbd className="rounded bg-gray-800 px-1 py-0.5">Esc</kbd>
                Close
              </span>
            </div>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
}