Tiptap advanced patterns extend beyond basic editing — Node.create({ name, group, parseHTML, renderHTML, addNodeView() { return ReactNodeViewRenderer(Component) } }) creates custom React node views. Mark.create({ name, parseHTML, renderHTML, addAttributes }) creates custom inline marks. Extension.create({ addCommands() { return { customCommand: () => ({ commands }) => commands.insertContent("x") } } }) adds editor commands. editor.commands.customCommand() invokes them. Slash commands: Suggestion({ char: "/", items: () => [...], render: () => ({ onStart, onUpdate, onKeyDown, onExit }) }) with a floating dropdown. @tiptap/extension-mention with custom render props for @mentions. Collaboration: Collaboration.configure({ document: ydoc }) + CollaborationCursor.configure({ provider }) with HocuspocusProvider. generateJSON(html, extensions) converts HTML to JSON. generateHTML(json, extensions) converts back. Claude Code generates Tiptap custom extensions, collaboration setup, AI autocomplete, and slash command menus.
CLAUDE.md for Tiptap Advanced
## Tiptap Advanced Stack
- Version: @tiptap/react >= 2.7, @tiptap/extension-* >= 2.7
- Custom node: Node.create({ name: "myNode", group: "block", addNodeView: () => ReactNodeViewRenderer(MyComponent) })
- Custom mark: Mark.create({ name: "highlight", parseHTML: () => [{ tag: "mark" }], renderHTML: ({ HTMLAttributes }) => ["mark", mergeAttributes(HTMLAttributes), 0] })
- Commands: addCommands() { return { setCustom: () => ({ commands }) => commands.setNode("custom") } }
- Suggestion: import Suggestion from "@tiptap/suggestion"; configure with char, items, render for custom menus
- Collaboration: Collaboration.configure({ document: ydoc }) — requires y-websocket or Hocuspocus server
- Export HTML: editor.getHTML(); Export JSON: editor.getJSON()
- Markdown: import { Markdown } from "tiptap-markdown"; editor.storage.markdown.getMarkdown()
Custom YouTube Embed Extension
// lib/editor/extensions/youtube.ts — custom Tiptap node extension
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { NodeViewWrapper } from "@tiptap/react"
// React component rendered as the node view
function YouTubeNodeView({ node, selected }: any) {
const videoId = node.attrs.videoId
const url = `https://www.youtube.com/embed/${videoId}`
return (
<NodeViewWrapper className="youtube-embed my-6" contentEditable={false}>
<div className={`relative aspect-video rounded-xl overflow-hidden ${selected ? "ring-2 ring-primary" : ""}`}>
<iframe
src={url}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</NodeViewWrapper>
)
}
export const YouTube = Node.create({
name: "youtube",
group: "block",
atom: true,
addAttributes() {
return {
videoId: { default: null },
startAt: { default: 0 },
width: { default: "100%" },
}
},
parseHTML() {
return [
{
tag: 'div[data-youtube-video]',
getAttrs: (element) => ({
videoId: (element as HTMLElement).dataset.videoId,
}),
},
]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes({ "data-youtube-video": true }, HTMLAttributes)]
},
addNodeView() {
return ReactNodeViewRenderer(YouTubeNodeView)
},
addCommands() {
return {
insertYouTube: (attrs: { videoId: string }) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs,
})
},
} as any
},
})
// Parse YouTube URL helper
export function extractVideoId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\s]+)/,
/youtube\.com\/embed\/([^?&\s]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match?.[1]) return match[1]
}
return null
}
Slash Commands Extension
// lib/editor/extensions/slashCommands.ts — /command slash menu
import { Extension } from "@tiptap/core"
import Suggestion from "@tiptap/suggestion"
import { ReactRenderer } from "@tiptap/react"
import tippy from "tippy.js"
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"
type CommandItem = {
title: string
description: string
icon: string
command: (editor: any) => void
}
const COMMANDS: CommandItem[] = [
{
title: "Heading 1",
description: "Large section heading",
icon: "H1",
command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
description: "Medium section heading",
icon: "H2",
command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
title: "Bullet List",
description: "Unordered list",
icon: "•",
command: (editor) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Numbered List",
description: "Ordered list",
icon: "1.",
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Quote",
description: "Block quote",
icon: "❝",
command: (editor) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
description: "Code with syntax highlighting",
icon: "</>",
command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
title: "Divider",
description: "Horizontal rule",
icon: "—",
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
},
]
const SlashCommandList = forwardRef<any, any>(function SlashCommandList(
{ items, command }: { items: CommandItem[]; command: (item: CommandItem) => void },
ref,
) {
const [selectedIndex, setSelectedIndex] = useState(0)
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i - 1 + items.length) % items.length)
return true
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length)
return true
}
if (event.key === "Enter") {
command(items[selectedIndex])
return true
}
return false
},
}))
useEffect(() => setSelectedIndex(0), [items])
return (
<div className="bg-popover border rounded-xl shadow-xl p-1 w-72 overflow-hidden">
{items.length ? (
items.map((item, i) => (
<button
key={item.title}
onClick={() => command(item)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<span className="size-8 rounded-md bg-muted flex items-center justify-center text-xs font-mono font-bold flex-shrink-0">
{item.icon}
</span>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</button>
))
) : (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">No results</div>
)}
</div>
)
})
export const SlashCommands = Extension.create({
name: "slashCommands",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: any) => {
props.command(editor)
editor.commands.deleteRange(range)
},
items: ({ query }: { query: string }) => {
return COMMANDS.filter((item) =>
item.title.toLowerCase().startsWith(query.toLowerCase()),
).slice(0, 10)
},
// Render the floating menu
render: () => {
let component: ReactRenderer
let popup: any
return {
onStart: (props: any) => {
component = new ReactRenderer(SlashCommandList, {
props,
editor: props.editor,
})
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
},
onUpdate: (props: any) => {
component.updateProps(props)
popup[0].setProps({ getReferenceClientRect: props.clientRect })
},
onKeyDown: ({ event }: any) => {
if (event.key === "Escape") { popup[0].hide(); return true }
return (component.ref as any).onKeyDown?.({ event })
},
onExit: () => {
popup[0].destroy()
component.destroy()
},
}
},
},
}
},
addProseMirrorPlugins() {
return [Suggestion({ editor: this.editor, ...this.options.suggestion })]
},
})
Rich Text Editor Component
// components/editor/RichTextEditor.tsx — full-featured Tiptap editor
"use client"
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Link from "@tiptap/extension-link"
import Image from "@tiptap/extension-image"
import { Markdown } from "tiptap-markdown"
import Highlight from "@tiptap/extension-highlight"
import TaskList from "@tiptap/extension-task-list"
import TaskItem from "@tiptap/extension-task-item"
import CharacterCount from "@tiptap/extension-character-count"
import { SlashCommands } from "@/lib/editor/extensions/slashCommands"
import { Bold, Italic, Strikethrough, Code, Link2, Highlighter } from "lucide-react"
interface RichTextEditorProps {
content?: string
onChange?: (markdown: string) => void
placeholder?: string
maxChars?: number
}
export function RichTextEditor({
content = "",
onChange,
placeholder = "Start writing... (type / for commands)",
maxChars = 50000,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder }),
Link.configure({ openOnClick: false, autolink: true }),
Image.configure({ inline: false }),
Highlight.configure({ multicolor: false }),
TaskList,
TaskItem.configure({ nested: true }),
CharacterCount.configure({ limit: maxChars }),
Markdown.configure({ html: false, transformPastedText: true }),
SlashCommands,
],
content,
onUpdate: ({ editor }) => {
onChange?.(editor.storage.markdown.getMarkdown())
},
editorProps: {
attributes: {
class: "prose prose-neutral dark:prose-invert max-w-none focus:outline-none min-h-[300px]",
},
},
})
if (!editor) return null
const charCount = editor.storage.characterCount.characters()
const charPercent = Math.min(100, (charCount / maxChars) * 100)
return (
<div className="rounded-xl border bg-card">
{/* Inline formatting bubble menu */}
<BubbleMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="flex items-center gap-0.5 bg-popover border rounded-xl shadow-lg p-1"
>
{[
{ icon: <Bold size={14} />, action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive("bold"), label: "Bold" },
{ icon: <Italic size={14} />, action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive("italic"), label: "Italic" },
{ icon: <Strikethrough size={14} />, action: () => editor.chain().focus().toggleStrike().run(), active: editor.isActive("strike"), label: "Strike" },
{ icon: <Code size={14} />, action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive("code"), label: "Code" },
{ icon: <Highlighter size={14} />, action: () => editor.chain().focus().toggleHighlight().run(), active: editor.isActive("highlight"), label: "Highlight" },
].map(({ icon, action, active, label }) => (
<button
key={label}
onClick={action}
title={label}
className={`p-1.5 rounded-lg transition-colors ${
active ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground"
}`}
>
{icon}
</button>
))}
</BubbleMenu>
{/* Editor content */}
<div className="p-6">
<EditorContent editor={editor} />
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-2 border-t text-xs text-muted-foreground">
<span>{charCount.toLocaleString()} characters</span>
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
charPercent > 90 ? "bg-destructive" : "bg-primary"
}`}
style={{ width: `${charPercent}%` }}
/>
</div>
<span>{charPercent.toFixed(0)}%</span>
</div>
</div>
</div>
)
}
For the Lexical alternative when a React-native rich text editor from Meta with native mobile support, a more flexible plugin architecture without the “extension” coupling, and better performance for very large documents (>100k chars) is needed — Meta uses Lexical in production for Facebook and Instagram, while Tiptap has a more ergonomic DX and better documentation for common use cases, see the Lexical guide. For the Slate alternative when a fully custom editor kernel where every primitive is exposed and overridden (including the rendering pipeline) is required for highly specialized editors — Slate gives the lowest-level control while Tiptap gives the best productivity for standard rich text editing, see the Slate guide. The Claude Skills 360 bundle includes Tiptap advanced skill sets covering custom extensions, slash commands, and collaboration. Start with the free tier to try rich text editor generation.