In the previous chapter, Custom Tiptap Extensions, we created powerful custom blocks like Tweet embeds and Highlighting.
But right now, the only way to use them is by pasting a URL or manually running code. That's not very user-friendly!
We need a "Magic Wand." In modern editors like Notion or Slack, you type / and a menu pops up, letting you choose what to insert.
Welcome to the Slash Command System.
Building a dropdown menu sounds easy, but doing it inside a text editor is surprisingly hard.
overflow: hidden, the popup menu might get cut off.We solve this using a technique called Tunneling.
Imagine you are in a submarine (the Editor). You want to fly a kite (the Menu). You can't fly it inside the submarine. You need to send a signal to the surface, and have a drone fly the kite up there, following the submarine's movement.
In novel:
/ key.To use the Slash Command, you need to understand three parts:
/.Let's look at how to set up the Slash Command menu in your application.
First, we need to decide what appears in the menu. We define an array of items. Each item has a title, an icon, and a command function that runs when clicked.
This code typically lives in your application (e.g., apps/web/components/tailwind/slash-command.tsx).
import { createSuggestionItems } from "novel";
import { Heading1, Text } from "lucide-react"; // Icons
export const myItems = createSuggestionItems([
{
title: "Text",
description: "Start typing with plain text.",
icon: <Text size={18} />,
command: ({ editor, range }) => {
// Turn the current line into a paragraph
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
},
},
// ... more items below
]);
Let's add a "Heading 1" command. When selected, it converts the current line into a big header.
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"], // For searching
icon: <Heading1 size={18} />,
command: ({ editor, range }) => {
editor.chain()
.focus()
.deleteRange(range) // Delete the "/" character
.setNode("heading", { level: 1 }) // Make it H1
.run();
},
}
Note: The searchTerms allow the user to type /big and still find "Heading 1".
Now we need to feed this list into the Editor. We use the slashCommand extension provided by novel.
import { Command } from "novel";
import { renderItems } from "novel"; // The rendering logic
export const slashCommand = Command.configure({
suggestion: {
items: () => myItems, // Our list from Step 1
render: renderItems, // How to draw it (Standard Novel renderer)
},
});
Finally, inside our React component, we use <EditorCommand> to define how the menu looks. This uses the cmdk library (a popular command palette library).
import { EditorCommand, EditorCommandList, EditorCommandItem } from "novel";
// Inside your Editor Component
<EditorCommand className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border bg-white px-1 py-2 shadow-md">
<EditorCommandList>
{/* The list is automatically populated based on search */}
{myItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command(val)}
className="flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-gray-100"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-gray-200 bg-white">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-gray-500">{item.description}</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
Wait, where do we put this <EditorCommand>?
You put it inside your editor component, but thanks to the "Tunneling" system we set up in Headless Editor Wrapper, it doesn't actually render there. It gets teleported to the popup location!
This is one of the most complex parts of the project. Let's trace exactly what happens when you type /.
Let's look at packages/headless/src/extensions/slash-command.tsx.
The core logic is in renderItems. This function creates a bridge between the raw Javascript world of Tiptap and the React world.
It uses a library called tippy.js to handle the positioning.
// packages/headless/src/extensions/slash-command.tsx
const renderItems = (elementRef) => {
return {
onStart: (props) => {
// 1. Create a React Renderer
component = new ReactRenderer(EditorCommandOut, {
props,
editor: props.editor,
});
// 2. Create the Popup (Tippy)
popup = tippy("body", {
getReferenceClientRect: props.clientRect, // Follow cursor
content: component.element, // Put React inside Tippy
showOnCreate: true,
});
},
// ... logic for onUpdate (typing) and onExit (closing)
};
};
You might notice EditorCommandOut above. This is the exit of our tunnel.
In packages/headless/src/components/editor-command.tsx, we see how the tunnel works using tunnel-rat.
// packages/headless/src/components/editor-command.tsx
// This component is rendered by Tiptap inside the popup
export const EditorCommandOut = ({ query, range }) => {
// It simply renders the Output of the tunnel
return (
<EditorCommandTunnelContext.Consumer>
{(tunnelInstance) => <tunnelInstance.Out />}
</EditorCommandTunnelContext.Consumer>
);
};
Conversely, the <EditorCommand> you wrote in your application is the Input:
// This is what you wrote in your app
export const EditorCommand = ({ children }) => {
return (
<tunnelInstance.In>
{/* Your menu items go into the tunnel here */}
<Command>
{children}
</Command>
</tunnelInstance.In>
);
};
Summary of the Magic:
tunnelInstance.In)./ and creates a popup.tunnelInstance.Out).You have now implemented a professional-grade Slash Command system.
/ key.This system is powerful because it allows you to keep your menu logic (React) separate from the positioning logic (Tiptap).
In the next chapter, we will take this menu to the next level. Instead of just picking static commands like "Heading 1", what if we could ask AI to write for us?
Next Chapter: AI Autocompletion Pipeline
Generated by Code IQ