A floating command menu triggered by '/' inspired by Notion. Features grouped commands, fuzzy search, keyboard navigation, and smooth positioning.
Type /h for headings · /m for media · ↑↓ navigate · ↵ select
// 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-xl border border-white/[0.12] bg-[#1a1a1a] shadow-2xl shadow-black/60 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
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Inline Data Table
Notion + Linear