A / command inserter popover with grouped, filterable block types and full arrow-key navigation, anchored to the editor line.
npx shadcn@latest add @paragon/slash-menuAlso installs: popover
"use client";
import * as React from "react";
import {
Type,
Heading1,
Heading2,
List,
ListChecks,
Quote,
Code,
Table,
Image,
Minus,
} from "lucide-react";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";
export interface SlashCommand {
id: string;
label: string;
hint: string;
icon: React.ElementType;
group: string;
/** Extra match terms for filtering. */
keywords?: string[];
}
export const defaultSlashCommands: SlashCommand[] = [
{ id: "text", label: "Text", hint: "Plain paragraph", icon: Type, group: "Basic" },
{ id: "h1", label: "Heading 1", hint: "Large section title", icon: Heading1, group: "Basic", keywords: ["title"] },
{ id: "h2", label: "Heading 2", hint: "Medium subsection", icon: Heading2, group: "Basic" },
{ id: "bullet", label: "Bulleted list", hint: "Simple bullet list", icon: List, group: "Lists", keywords: ["ul"] },
{ id: "todo", label: "To-do list", hint: "Track tasks with checkboxes", icon: ListChecks, group: "Lists", keywords: ["checkbox", "task"] },
{ id: "quote", label: "Quote", hint: "Capture a callout", icon: Quote, group: "Basic" },
{ id: "code", label: "Code", hint: "Monospace code block", icon: Code, group: "Advanced", keywords: ["snippet"] },
{ id: "table", label: "Table", hint: "Add a simple table", icon: Table, group: "Advanced" },
{ id: "image", label: "Image", hint: "Upload or embed", icon: Image, group: "Media" },
{ id: "divider", label: "Divider", hint: "Visual separator", icon: Minus, group: "Basic", keywords: ["hr", "rule"] },
];
export interface SlashMenuProps {
commands?: SlashCommand[];
/** Called with the chosen command id. */
onSelect?: (id: string) => void;
placeholder?: string;
}
/**
* A "/" command inserter. Typing "/" in the field opens a grouped popover of
* block types; further typing filters by label, hint and keywords. Arrow keys
* move a highlight (which auto-scrolls into view), Enter inserts, Escape
* closes. Selection is index-driven so it never depends on hover, and the
* anchor keeps the popover pinned to the caret line. Zero-props renders a
* ready-to-drive editor line.
*/
export function SlashMenu({
commands = defaultSlashCommands,
onSelect,
placeholder = "Type / for commands…",
}: SlashMenuProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return commands;
return commands.filter((c) =>
[c.label, c.hint, ...(c.keywords ?? [])]
.join(" ")
.toLowerCase()
.includes(q),
);
}, [commands, query]);
const groups = React.useMemo(() => {
const map = new Map<string, SlashCommand[]>();
for (const command of filtered) {
const list = map.get(command.group) ?? [];
list.push(command);
map.set(command.group, list);
}
return [...map.entries()];
}, [filtered]);
// Flat, in-render order so arrow keys traverse groups seamlessly.
const flat = React.useMemo(() => groups.flatMap(([, list]) => list), [groups]);
React.useEffect(() => {
setActive(0);
}, [query, open]);
React.useEffect(() => {
if (!open) return;
const node = listRef.current?.querySelector<HTMLElement>(
`[data-index="${active}"]`,
);
node?.scrollIntoView({ block: "nearest" });
}, [active, open]);
const commit = (id: string) => {
onSelect?.(id);
setOpen(false);
setQuery("");
inputRef.current?.focus();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "/" && !open) {
setOpen(true);
return;
}
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActive((i) => (flat.length ? (i + 1) % flat.length : 0));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => (flat.length ? (i - 1 + flat.length) % flat.length : 0));
} else if (event.key === "Enter") {
event.preventDefault();
const command = flat[active];
if (command) commit(command.id);
} else if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
}
};
let runningIndex = -1;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className="w-full max-w-md">
<input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
if (!open) setOpen(true);
}}
onKeyDown={onKeyDown}
placeholder={placeholder}
aria-label="Editor line"
aria-expanded={open}
className={cn(
"h-10 w-full rounded-lg bg-card px-3 text-sm text-card-foreground shadow-border outline-none",
"placeholder:text-muted-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
/>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={6}
onOpenAutoFocus={(event) => event.preventDefault()}
className="w-72 p-1"
>
<div
ref={listRef}
role="listbox"
aria-label="Insert block"
className="max-h-72 overflow-y-auto [scrollbar-width:thin]"
>
{flat.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
No blocks match “{query}”
</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="mb-1 last:mb-0">
<p className="px-2 pt-1.5 pb-1 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
{group}
</p>
{list.map((command) => {
runningIndex += 1;
const index = runningIndex;
const Icon = command.icon;
const isActive = index === active;
return (
<button
key={command.id}
type="button"
role="option"
aria-selected={isActive}
data-index={index}
onMouseEnter={() => setActive(index)}
onClick={() => commit(command.id)}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left outline-none",
isActive ? "bg-accent text-accent-foreground" : "text-foreground",
)}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-secondary text-muted-foreground">
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{command.label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{command.hint}
</span>
</span>
</button>
);
})}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
);
}