Command Palette

A keyboard-driven command palette with fuzzy search, arrow key navigation, and shortcut badges. Inspired by Linear's Cmd+K interface.

Command PalettesInspired by Linear
Live PreviewInteractive

Click the button or press ⌘K to open

CommandPalette.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useEffect, useRef } from 'react';
import {
  Search,
  Plus,
  FileText,
  Settings,
  Users,
  Bell,
  HelpCircle,
  ArrowRight,
} from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

type Command = {
  id: string;
  icon: React.ReactNode;
  label: string;
  shortcut?: string[];
  group: string;
  onSelect: () => void;
};

const COMMANDS: Command[] = [
  {
    id: 'new-issue',
    icon: <Plus className="w-4 h-4" />,
    label: 'Create new issue',
    shortcut: ['C'],
    group: 'Create',
    onSelect: () => console.log('Executed: Create new issue'),
  },
  {
    id: 'my-issues',
    icon: <FileText className="w-4 h-4" />,
    label: 'My issues',
    shortcut: ['I'],
    group: 'Navigate',
    onSelect: () => console.log('Executed: My issues'),
  },
  {
    id: 'team',
    icon: <Users className="w-4 h-4" />,
    label: 'Team members',
    group: 'Navigate',
    onSelect: () => console.log('Executed: Team members'),
  },
  {
    id: 'notifications',
    icon: <Bell className="w-4 h-4" />,
    label: 'Notifications',
    shortcut: ['N'],
    group: 'Navigate',
    onSelect: () => console.log('Executed: Notifications'),
  },
  {
    id: 'settings',
    icon: <Settings className="w-4 h-4" />,
    label: 'Settings',
    shortcut: ['G', 'S'],
    group: 'Navigate',
    onSelect: () => console.log('Executed: Settings'),
  },
  {
    id: 'help',
    icon: <HelpCircle className="w-4 h-4" />,
    label: 'Help & documentation',
    shortcut: ['?'],
    group: 'Help',
    onSelect: () => console.log('Executed: Help & documentation'),
  },
];

type Props = {
  isOpen: boolean;
  onClose: () => void;
};

export function CommandPalette({ isOpen, onClose }: Props) {
  const [query, setQuery] = useState('');
  const [selectedIndex, setSelectedIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);

  const filtered = COMMANDS.filter((cmd) =>
    cmd.label.toLowerCase().includes(query.toLowerCase())
  );

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

  const filteredRef = useRef(filtered);
  filteredRef.current = filtered;
  const selectedIndexRef = useRef(selectedIndex);
  selectedIndexRef.current = selectedIndex;

  useEffect(() => {
    if (isOpen) {
      setQuery('');
      setSelectedIndex(0);
      setTimeout(() => inputRef.current?.focus(), 0);
    }
  }, [isOpen]);

  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if (!isOpen) return;
      if (e.key === 'Escape') {
        onClose();
        return;
      }
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setSelectedIndex((i) => Math.min(i + 1, filteredRef.current.length - 1));
      }
      if (e.key === 'ArrowUp') {
        e.preventDefault();
        setSelectedIndex((i) => Math.max(i - 1, 0));
      }
      if (e.key === 'Enter' && filteredRef.current.length > 0) {
        filteredRef.current[selectedIndexRef.current].onSelect();
        onClose();
      }
    };
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] bg-black/60 backdrop-blur-sm"
      onClick={onClose}
    >
      <div
        className="w-full max-w-[560px] mx-4 bg-zinc-900 border border-white/10 rounded-xl shadow-2xl overflow-hidden"
        onClick={(e) => e.stopPropagation()}
      >
        {/* Search input */}
        <div className="flex items-center gap-3 px-4 border-b border-white/[0.08]">
          <Search className="w-4 h-4 text-zinc-500 shrink-0" />
          <input
            ref={inputRef}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search commands..."
            className="flex-1 py-4 bg-transparent text-sm text-white placeholder-zinc-500 outline-none"
          />
          <kbd className="text-[10px] text-zinc-600 bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5">
            ESC
          </kbd>
        </div>

        {/* Results list */}
        <div className="py-2 max-h-[320px] overflow-y-auto">
          {filtered.length === 0 ? (
            <p className="px-4 py-8 text-sm text-zinc-500 text-center">
              No commands found.
            </p>
          ) : (
            filtered.map((cmd, i) => (
              <button
                key={cmd.id}
                className={cn(
                  'w-full flex items-center gap-3 px-3 py-2.5 mx-1 rounded-lg text-left transition-colors',
                  i === selectedIndex
                    ? 'bg-white/[0.08] text-white'
                    : 'text-zinc-400 hover:bg-white/[0.04] hover:text-zinc-200'
                )}
                style={{ width: 'calc(100% - 8px)' }}
                onMouseEnter={() => setSelectedIndex(i)}
                onClick={() => { cmd.onSelect(); onClose(); }}
              >
                <span className="w-7 h-7 flex items-center justify-center rounded-md bg-zinc-800 text-zinc-400 shrink-0">
                  {cmd.icon}
                </span>
                <span className="flex-1 text-sm font-medium">{cmd.label}</span>
                {cmd.shortcut && (
                  <span className="flex items-center gap-1 shrink-0">
                    {cmd.shortcut.map((key) => (
                      <kbd
                        key={key}
                        className="text-[10px] text-zinc-500 bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5"
                      >
                        {key}
                      </kbd>
                    ))}
                  </span>
                )}
                {i === selectedIndex && (
                  <ArrowRight className="w-3.5 h-3.5 text-zinc-500 shrink-0 ml-1" />
                )}
              </button>
            ))
          )}
        </div>

        {/* Keyboard hint footer */}
        <div className="flex items-center gap-4 px-4 py-2.5 border-t border-white/[0.08] bg-black/20">
          <span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
            <kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">
              ↑↓
            </kbd>
            navigate
          </span>
          <span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
            <kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">

            </kbd>
            select
          </span>
          <span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
            <kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">
              esc
            </kbd>
            close
          </span>
        </div>
      </div>
    </div>
  );
}