Slash Command Menu

A floating command menu triggered by '/' inspired by Notion. Features grouped commands, fuzzy search, keyboard navigation, and smooth positioning.

Command PalettesInspired by Notion
Live PreviewInteractive
Untitled Document
Draft · edited just now
|
/Type to filter commands...
BASIC BLOCKS
MEDIA
ADVANCED

Type /h for headings  ·  /m for media  ·  ↑↓ navigate  ·  ↵ select

SlashCommandMenu.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useEffect, useRef, useMemo } from 'react';
import {
  AlignLeft, Heading1, Heading2, Heading3, List, ListOrdered,
  CheckSquare, ChevronRight, Quote, Minus, Image, Video, Code,
  File, Link, Table, Database, Info, Calculator,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';

type SlashCommand = {
  id: string;
  label: string;
  description: string;
  icon: LucideIcon;
  category: string;
  keywords: string[];
  hint: string;
};

const COMMANDS: SlashCommand[] = [
  { id: 'text', label: 'Text', description: 'Start writing with plain text', icon: AlignLeft, category: 'BASIC BLOCKS', keywords: ['text', 'paragraph', 'plain', 'write'], hint: '/text' },
  { id: 'h1', label: 'Heading 1', description: 'Large section heading', icon: Heading1, category: 'BASIC BLOCKS', keywords: ['heading', 'h1', 'title', 'large'], hint: '/h1' },
  { id: 'h2', label: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'BASIC BLOCKS', keywords: ['heading', 'h2', 'subtitle', 'medium'], hint: '/h2' },
  { id: 'h3', label: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'BASIC BLOCKS', keywords: ['heading', 'h3', 'small'], hint: '/h3' },
  { id: 'bullet', label: 'Bullet List', description: 'Create a bulleted list', icon: List, category: 'BASIC BLOCKS', keywords: ['bullet', 'list', 'unordered'], hint: '/bullet' },
  { id: 'num', label: 'Numbered List', description: 'Create a numbered list', icon: ListOrdered, category: 'BASIC BLOCKS', keywords: ['numbered', 'ordered', 'list'], hint: '/num' },
  { id: 'todo', label: 'To-do', description: 'Track tasks with checkboxes', icon: CheckSquare, category: 'BASIC BLOCKS', keywords: ['todo', 'task', 'checkbox'], hint: '/todo' },
  { id: 'toggle', label: 'Toggle', description: 'Collapsible content block', icon: ChevronRight, category: 'BASIC BLOCKS', keywords: ['toggle', 'collapse', 'accordion'], hint: '/toggle' },
  { id: 'quote', label: 'Quote', description: 'Highlight a quote or callout', icon: Quote, category: 'BASIC BLOCKS', keywords: ['quote', 'blockquote', 'callout'], hint: '/quote' },
  { id: 'divider', label: 'Divider', description: 'Visual separator line', icon: Minus, category: 'BASIC BLOCKS', keywords: ['divider', 'separator', 'line'], hint: '/divider' },
  { id: 'image', label: 'Image', description: 'Upload or embed an image', icon: Image, category: 'MEDIA', keywords: ['image', 'photo', 'picture', 'upload'], hint: '/image' },
  { id: 'video', label: 'Video', description: 'Embed a video from URL', icon: Video, category: 'MEDIA', keywords: ['video', 'youtube', 'embed'], hint: '/video' },
  { id: 'code', label: 'Code', description: 'Display a code snippet', icon: Code, category: 'MEDIA', keywords: ['code', 'snippet', 'programming'], hint: '/code' },
  { id: 'file', label: 'File', description: 'Upload any file', icon: File, category: 'MEDIA', keywords: ['file', 'attachment', 'upload'], hint: '/file' },
  { id: 'embed', label: 'Embed', description: 'Embed any URL', icon: Link, category: 'MEDIA', keywords: ['embed', 'url', 'link', 'iframe'], hint: '/embed' },
  { id: 'table', label: 'Table', description: 'Insert a data table', icon: Table, category: 'ADVANCED', keywords: ['table', 'grid', 'data'], hint: '/table' },
  { id: 'database', label: 'Database', description: 'Create a linked database', icon: Database, category: 'ADVANCED', keywords: ['database', 'db', 'linked'], hint: '/db' },
  { id: 'callout', label: 'Callout', description: 'Highlighted info block', icon: Info, category: 'ADVANCED', keywords: ['callout', 'info', 'tip', 'warning', 'note'], hint: '/callout' },
  { id: 'math', label: 'Math', description: 'Insert a math equation', icon: Calculator, category: 'ADVANCED', keywords: ['math', 'equation', 'latex'], hint: '/math' },
];

type Props = {
  isOpen: boolean;
  onClose: () => void;
  onSelect: (command: SlashCommand) => void;
  position?: { top: number; left: number };
  searchQuery?: string;
};

export function SlashCommandMenu({ isOpen, onClose, onSelect, position, searchQuery = '' }: Props) {
  const [activeIndex, setActiveIndex] = useState(0);
  const listRef = useRef<HTMLDivElement>(null);

  const filtered = useMemo(() => {
    if (!searchQuery.trim()) return COMMANDS;
    const q = searchQuery.toLowerCase();
    return COMMANDS.filter(
      (cmd) =>
        cmd.label.toLowerCase().includes(q) ||
        cmd.hint.slice(1).includes(q) ||
        cmd.keywords.some((k) => k.includes(q))
    );
  }, [searchQuery]);

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

  useEffect(() => {
    if (!isOpen) return;
    const handle = (e: KeyboardEvent) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setActiveIndex((i) => (i + 1) % Math.max(filtered.length, 1));
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        setActiveIndex((i) => (i - 1 + Math.max(filtered.length, 1)) % Math.max(filtered.length, 1));
      } else if (e.key === 'Enter') {
        e.preventDefault();
        if (filtered[activeIndex]) onSelect(filtered[activeIndex]);
      } else if (e.key === 'Escape') {
        e.preventDefault();
        onClose();
      }
    };
    window.addEventListener('keydown', handle);
    return () => window.removeEventListener('keydown', handle);
  }, [isOpen, filtered, activeIndex, onSelect, onClose]);

  useEffect(() => {
    listRef.current?.querySelector("[data-active='true']")?.scrollIntoView({ block: 'nearest' });
  }, [activeIndex]);

  if (!isOpen) return null;

  const grouped: Record<string, SlashCommand[]> = {};
  for (const cmd of filtered) {
    if (!grouped[cmd.category]) grouped[cmd.category] = [];
    grouped[cmd.category].push(cmd);
  }

  return (
    <div
      className="absolute z-50 w-80 rounded-lg border border-[#2a2a2a] bg-[#1a1a1a] shadow-2xl overflow-hidden"
      style={position ? { top: position.top, left: position.left } : undefined}
    >
      <div className="flex items-center gap-2 px-3 py-2.5 border-b border-white/[0.08]">
        <span className="text-zinc-400 text-sm font-mono">/</span>
        <span className="text-sm flex-1">
          {searchQuery ? (
            <span className="text-white">{searchQuery}</span>
          ) : (
            <span className="text-zinc-500">Type to filter commands...</span>
          )}
        </span>
      </div>
      <div ref={listRef} className="max-h-[336px] overflow-y-auto">
        {Object.keys(grouped).length === 0 ? (
          <div className="py-8 text-center px-4">
            <p className="text-zinc-500 text-sm">No commands found for "{searchQuery}"</p>
          </div>
        ) : (
          Object.entries(grouped).map(([category, cmds]) => (
            <div key={category}>
              <div className="px-3 pt-3 pb-1 text-[10px] font-semibold text-zinc-600 tracking-wider">
                {category}
              </div>
              {cmds.map((cmd) => {
                const idx = filtered.indexOf(cmd);
                const active = idx === activeIndex;
                const Icon = cmd.icon;
                return (
                  <button
                    key={cmd.id}
                    data-active={active}
                    className={"w-full flex items-center gap-3 px-3 py-2 text-left transition-colors " + (active ? "bg-white/[0.08]" : "hover:bg-white/[0.04]")}
                    onMouseEnter={() => setActiveIndex(idx)}
                    onClick={() => onSelect(cmd)}
                  >
                    <div className="w-8 h-8 rounded-md bg-zinc-800/80 border border-white/[0.08] flex items-center justify-center shrink-0">
                      <Icon className="w-3.5 h-3.5 text-zinc-400" />
                    </div>
                    <div className="flex-1 min-w-0">
                      <div className="text-[13px] font-medium text-white">{cmd.label}</div>
                      <div className="text-[11px] text-zinc-500 truncate">{cmd.description}</div>
                    </div>
                    <span className="text-[10px] text-zinc-600 font-mono shrink-0">{cmd.hint}</span>
                  </button>
                );
              })}
            </div>
          ))
        )}
      </div>
    </div>
  );
}

Unlock to copy

Free access to all patterns