SaaS & Ops

Slash Menu

A / command inserter popover with grouped, filterable block types and full arrow-key navigation, anchored to the editor line.

Install

npx shadcn@latest add @paragon/slash-menu

Also installs: popover

slash-menu.tsx

"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>
  );
}