Command Palette

A clean, keyboard-accessible command palette built with React, Tailwind CSS, and Framer Motion. Inspired by modern application interfaces, it provides a seamless way for users to search and execute actions across your platform.

Live Preview

Dependencies

npm installlucide-reactframer-motion

Code

"use client";

import { useEffect, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Home, Settings, LogOut, User, Search, Command } from "lucide-react";

type Command = {
  id: string;
  label: string;
  group: string;
  icon: React.ReactNode;
  action: () => void;
};

const COMMANDS: Command[] = [
  {
    id: "home",
    label: "Go to Dashboard",
    group: "Navigation",
    icon: <Home size={16} />,
    action: () => alert("Dashboard"),
  },
  {
    id: "profile",
    label: "Open Profile",
    group: "Navigation",
    icon: <User size={16} />,
    action: () => alert("Profile"),
  },
  {
    id: "settings",
    label: "Open Settings",
    group: "Preferences",
    icon: <Settings size={16} />,
    action: () => alert("Settings"),
  },
  {
    id: "logout",
    label: "Logout",
    group: "Account",
    icon: <LogOut size={16} />,
    action: () => alert("Logout"),
  },
];

export default function CommandPalette() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setOpen((prev) => !prev);
      }

      if (e.key === "Escape") setOpen(false);
    };

    window.addEventListener("keydown", down);
    return () => window.removeEventListener("keydown", down);
  }, []);

  // Filter commands
  const filtered = useMemo(() => {
    return COMMANDS.filter((cmd) =>
      cmd.label.toLowerCase().includes(query.toLowerCase()),
    );
  }, [query]);

  // Group commands
  const grouped = useMemo(() => {
    const map: Record<string, Command[]> = {};
    filtered.forEach((cmd) => {
      if (!map[cmd.group]) map[cmd.group] = [];
      map[cmd.group].push(cmd);
    });
    return map;
  }, [filtered]);

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

    const handler = (e: KeyboardEvent) => {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
      }

      if (e.key === "ArrowUp") {
        e.preventDefault();
        setActiveIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
      }

      if (e.key === "Enter") {
        filtered[activeIndex]?.action();
        setOpen(false);
      }
    };

    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [filtered, activeIndex, open]);

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

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="flex items-center cursor-pointer gap-3 px-4 py-2.5 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm hover:border-zinc-300 dark:hover:border-zinc-700 transition-all text-sm text-zinc-700 dark:text-zinc-300 font-medium group"
      >
        <span>Search commands</span>
        <kbd className="px-1.5 flex items-center gap-1 py-0.5 text-[13px] font-semibold text-zinc-400 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md">
          <Command size={15} /> + K
        </kbd>
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            className="fixed inset-0 z-50 bg-zinc-950/20 backdrop-blur-[6px] flex items-start justify-center pt-[120px] px-4"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            <motion.div
              initial={{ scale: 0.95, y: -10 }}
              animate={{ scale: 1, y: 0 }}
              exit={{ scale: 0.95, y: -10 }}
              transition={{ type: "spring", stiffness: 400, damping: 30 }}
              className="w-full max-w-[560px] bg-white dark:bg-zinc-900 border border-zinc-200/60 dark:border-zinc-800 rounded-2xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] overflow-hidden flex flex-col"
            >
              <div className="flex items-center gap-3 px-5 border-b border-zinc-100 dark:border-zinc-800">
                <Search className="text-zinc-400 shrink-0" size={18} />
                <input
                  autoFocus
                  value={query}
                  onChange={(e) => setQuery(e.target.value)}
                  placeholder="Search commands or actions..."
                  className="w-full bg-transparent py-4 outline-none text-zinc-900 dark:text-zinc-50 placeholder:text-zinc-400 text-sm font-medium"
                />
              </div>

              <div className="p-3 max-h-[360px] overflow-y-auto flex flex-col gap-4">
                {Object.entries(grouped).map(([group, cmds]) => (
                  <div key={group} className="flex flex-col gap-1">
                    <span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider px-3 select-none">
                      {group}
                    </span>

                    {cmds.map((cmd) => {
                      const index = filtered.findIndex((f) => f.id === cmd.id);
                      const isActive = index === activeIndex;

                      return (
                        <motion.div
                          key={cmd.id}
                          onClick={() => {
                            cmd.action();
                            setOpen(false);
                          }}
                          className={`flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${
                            isActive
                              ? "bg-zinc-100/80 dark:bg-zinc-800/80 text-zinc-900 dark:text-zinc-100 shadow-sm border-zinc-200/40 dark:border-zinc-700/50"
                              : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800/40"
                          }`}
                          whileTap={{ scale: 0.98 }}
                        >
                          <div className="flex items-center gap-3">
                            <span
                              className={`shrink-0 ${
                                isActive
                                  ? "text-zinc-900 dark:text-zinc-100"
                                  : "text-zinc-400"
                              }`}
                            >
                              {cmd.icon}
                            </span>
                            <span className="text-sm font-medium">
                              {cmd.label}
                            </span>
                          </div>
                          {isActive && (
                            <span className="text-xs text-zinc-400 font-medium px-1.5 py-0.5 rounded border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 shadow-sm">
                              Enter
                            </span>
                          )}
                        </motion.div>
                      );
                    })}
                  </div>
                ))}

                {filtered.length === 0 && (
                  <div className="flex flex-col items-center justify-center py-12 text-zinc-400 gap-2">
                    <p className="text-sm font-medium">No results found</p>
                    <p className="text-xs text-zinc-500">
                      Try adjusting your search terms
                    </p>
                  </div>
                )}
              </div>

              {/* Footer */}
              <div className="px-5 py-3 border-t border-zinc-100 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/40 flex items-center justify-between text-[11px] text-zinc-400 select-none">
                <div>
                  Use <span className="font-bold">↑↓</span> to navigate,{" "}
                  <span className="font-bold">↵</span> to select
                </div>
                <div>ESC to close</div>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

Data Used

PropTypeDefaultDescription
idstring"home"A unique identifier for the command.
labelstring"Go to Dashboard"The display text for the command shown in the palette.
groupstring"Navigation"The category under which the command is grouped.
iconReact.ReactNodeundefinedThe icon component rendered alongside the label.
action() => void() => {}The function executed when the command is selected.