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-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
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
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Status Badge System
Linear
Segmented Control
Notion