typescriptadvanced

Command Palette Component

Keyboard-driven command palette with fuzzy search, keyboard navigation, and grouping.

typescript
import { useState, useEffect, useRef, useMemo } from 'react';

interface Command {
  id: string;
  label: string;
  group?: string;
  shortcut?: string;
  action: () => void;
}

interface Props {
  commands: Command[];
  isOpen: boolean;
  onClose: () => void;
}

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

  const filtered = useMemo(
    () => commands.filter(c => c.label.toLowerCase().includes(query.toLowerCase())),
    [commands, query]
  );

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

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (!isOpen) return;
      if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, filtered.length - 1)); }
      if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
      if (e.key === 'Enter' && filtered[selected]) { filtered[selected].action(); onClose(); }
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [isOpen, filtered, selected, onClose]);

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 bg-black/50 flex items-start justify-center pt-[20vh] z-50" onClick={onClose}>
      <div className="bg-[#111] border border-white/10 rounded-xl w-full max-w-lg" onClick={e => e.stopPropagation()}>
        <input
          ref={inputRef}
          value={query}
          onChange={e => { setQuery(e.target.value); setSelected(0); }}
          placeholder="Type a command..."
          className="w-full p-4 bg-transparent border-b border-white/10 outline-none text-white"
        />
        <ul className="max-h-64 overflow-y-auto">
          {filtered.map((cmd, i) => (
            <li
              key={cmd.id}
              className={`px-4 py-2 cursor-pointer flex justify-between ${i === selected ? 'bg-blue-500/20' : 'hover:bg-white/5'}`}
              onClick={() => { cmd.action(); onClose(); }}
            >
              <span>{cmd.label}</span>
              {cmd.shortcut && <kbd className="text-xs text-gray-500">{cmd.shortcut}</kbd>}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Use Cases

  • App navigation
  • Power user features

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.