Tiptap is a headless rich text editor built on ProseMirror — it provides the editing logic without any UI, letting you style everything with Tailwind or CSS. useEditor({ extensions, content }) initializes the editor in React. StarterKit bundles Heading, Bold, Italic, Code, Blockquote, BulletList, and Paragraph. Custom Node extensions add new block types like callouts or image captions; Mark extensions add inline styles. The suggestion extension powers slash commands — a command palette triggered by /. @tiptap/extension-collaboration and @hocuspocus/provider add real-time multi-user editing via Y.js CRDTs. BubbleMenu renders a floating formatting toolbar when text is selected. FloatingMenu appears on empty lines for block-level commands. editor.getJSON() serializes to JSON; editor.getHTML() to HTML. Claude Code generates Tiptap editor configurations, custom extensions, slash command implementations, and the collaboration setup for document editing applications.
CLAUDE.md for Tiptap
## Tiptap Stack
- Version: @tiptap/react >= 2.10, @tiptap/starter-kit >= 2.10
- Init: useEditor({ extensions: [...], content: initialContent, onUpdate: ({ editor }) => {...} })
- StarterKit: Heading/Bold/Italic/Code/Blockquote/BulletList/OrderedList/HardBreak/History
- Custom Node: Node.create({ name, group, content, parseHTML, renderHTML, addNodeView })
- Custom Mark: Mark.create({ name, parseHTML, renderHTML, addAttributes })
- Slash: Suggestion extension + renderItems callback — command palette
- Collab: Collaboration (Y.js) + CollaborationCursor + HocuspocusProvider
- Output: editor.getJSON() | editor.getHTML() | editor.getText()
Editor Setup
// components/editor/RichTextEditor.tsx — main editor component
"use client"
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { Link } from "@tiptap/extension-link"
import { Image } from "@tiptap/extension-image"
import { Placeholder } from "@tiptap/extension-placeholder"
import { CharacterCount } from "@tiptap/extension-character-count"
import { SlashCommands } from "./extensions/SlashCommands"
import { Callout } from "./extensions/Callout"
import { EditorToolbar } from "./EditorToolbar"
import { BubbleToolbar } from "./BubbleToolbar"
import type { JSONContent } from "@tiptap/core"
interface RichTextEditorProps {
content?: JSONContent
onChange?: (content: JSONContent) => void
onBlur?: () => void
placeholder?: string
readOnly?: boolean
maxLength?: number
}
export function RichTextEditor({
content,
onChange,
onBlur,
placeholder = "Write something...",
readOnly = false,
maxLength,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
code: { HTMLAttributes: { class: "rounded bg-muted px-1 py-0.5 font-mono text-sm" } },
codeBlock: false, // Using custom CodeBlock extension instead
}),
Placeholder.configure({ placeholder }),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: "text-blue-600 underline" },
}),
Image.configure({
HTMLAttributes: { class: "max-w-full rounded-lg my-4" },
}),
CharacterCount.configure({ limit: maxLength }),
SlashCommands,
Callout,
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => {
onChange?.(editor.getJSON())
},
onBlur: () => onBlur?.(),
editorProps: {
attributes: {
class: "prose prose-stone max-w-none focus:outline-none min-h-[200px] px-4 py-3",
},
},
})
if (!editor) return null
return (
<div className="border rounded-lg overflow-hidden">
{!readOnly && <EditorToolbar editor={editor} />}
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<BubbleToolbar editor={editor} />
</BubbleMenu>
<FloatingMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="text-muted-foreground text-sm px-2">
Type / for commands
</div>
</FloatingMenu>
<EditorContent editor={editor} />
{maxLength && (
<div className="flex justify-end px-4 py-2 text-xs text-muted-foreground border-t">
{editor.storage.characterCount.characters()} / {maxLength}
</div>
)}
</div>
)
}
Custom Node Extension
// components/editor/extensions/Callout.ts — custom block extension
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { CalloutView } from "./CalloutView"
export type CalloutType = "info" | "warning" | "success" | "error"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
callout: {
setCallout: (attrs: { type: CalloutType }) => ReturnType
}
}
}
export const Callout = Node.create({
name: "callout",
group: "block",
content: "inline*",
defining: true,
addAttributes() {
return {
type: {
default: "info",
parseHTML: element => element.getAttribute("data-type"),
renderHTML: attributes => ({ "data-type": attributes.type }),
},
}
},
parseHTML() {
return [{ tag: "div[data-callout]" }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes({ "data-callout": "true" }, HTMLAttributes), 0]
},
addNodeView() {
return ReactNodeViewRenderer(CalloutView)
},
addCommands() {
return {
setCallout:
attrs =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs,
content: [{ type: "text", text: "" }],
})
},
}
},
})
Slash Commands Extension
// components/editor/extensions/SlashCommands.ts
import { Extension } from "@tiptap/core"
import Suggestion from "@tiptap/suggestion"
import { ReactRenderer } from "@tiptap/react"
import tippy from "tippy.js"
import { CommandsList } from "./CommandsList"
const slashCommands = [
{
title: "Heading 1",
description: "Large section heading",
icon: "H1",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run()
},
},
{
title: "Heading 2",
description: "Medium section heading",
icon: "H2",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run()
},
},
{
title: "Bullet List",
description: "Create a simple bullet list",
icon: "•",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run()
},
},
{
title: "Callout",
description: "Highlighted information block",
icon: "💡",
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setCallout({ type: "info" }).run()
},
},
]
export const SlashCommands = Extension.create({
name: "slashCommands",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: any) => {
props.command({ editor, range })
},
items: ({ query }: { query: string }) =>
slashCommands
.filter(cmd =>
cmd.title.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 10),
render: () => {
let component: ReactRenderer
let popup: ReturnType<typeof tippy>
return {
onStart: (props: any) => {
component = new ReactRenderer(CommandsList, {
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: (props: any) => {
if (props.event.key === "Escape") {
popup?.[0]?.hide()
return true
}
return (component?.ref as any)?.onKeyDown?.(props)
},
onExit: () => {
popup?.[0]?.destroy()
component?.destroy()
},
}
},
},
}
},
addProseMirrorPlugins() {
return [Suggestion({ editor: this.editor, ...this.options.suggestion })]
},
})
Collaboration with Hocuspocus
// components/editor/CollaborativeEditor.tsx — real-time collaboration
"use client"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Collaboration from "@tiptap/extension-collaboration"
import CollaborationCursor from "@tiptap/extension-collaboration-cursor"
import { HocuspocusProvider } from "@hocuspocus/provider"
import * as Y from "yjs"
import { useEffect, useRef } from "react"
import { useSession } from "@/lib/auth"
export function CollaborativeEditor({ documentId }: { documentId: string }) {
const { user } = useSession()
const ydoc = useRef(new Y.Doc()).current
const provider = useRef(
new HocuspocusProvider({
url: process.env.NEXT_PUBLIC_HOCUSPOCUS_URL!,
name: documentId,
document: ydoc,
token: () => fetch("/api/hocuspocus-token").then(r => r.text()),
})
).current
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }), // Disabled — Y.js handles undo
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: {
name: user?.name ?? "Anonymous",
color: `#${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0")}`,
},
}),
],
})
useEffect(() => {
return () => {
provider.destroy()
ydoc.destroy()
}
}, [])
return (
<div className="relative">
<div className="flex gap-2 p-2 border-b">
{Array.from((editor?.storage.collaborationCursor.users ?? []) as any[]).map((u: any) => (
<div
key={u.clientId}
className="h-7 w-7 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: u.color }}
title={u.name}
>
{u.name?.[0]?.toUpperCase()}
</div>
))}
</div>
<EditorContent editor={editor} />
</div>
)
}
For the Lexical editor alternative from Meta that provides a similar extensible editor framework with a different node-based architecture, better performance on very large documents, and native support for React 18 concurrent features, the React patterns guide covers editor integration. For the Quill.js lightweight editor alternative when a simpler editing experience without ProseMirror’s complexity and a maturer browser compatibility story for legacy applications is needed, consider the basic editor patterns. The Claude Skills 360 bundle includes Tiptap skill sets covering extensions, slash commands, and collaboration. Start with the free tier to try Tiptap editor configuration generation.