Tiptap is a headless editor framework built on ProseMirror. You control the UI entirely while getting powerful editing features out of the box.
Install Dependencies
pnpm add @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder @tiptap/extension-underline @tiptap/extension-text-align @tiptap/extension-code-block-lowlight @tiptap/extension-color @tiptap/extension-text-style
Editor Component
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import { EditorToolbar } from "./EditorToolbar";
interface RichTextEditorProps {
content?: string;
onChange?: (html: string) => void;
placeholder?: string;
editable?: boolean;
}
export function RichTextEditor({
content = "",
onChange,
placeholder = "Start writing...",
editable = true,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: false,
}),
Image.configure({
HTMLAttributes: { class: "rounded-lg max-w-full" },
}),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: "text-primary underline" },
}),
Placeholder.configure({ placeholder }),
Underline,
TextAlign.configure({ types: ["heading", "paragraph"] }),
],
content,
editable,
onUpdate: ({ editor }) => {
onChange?.(editor.getHTML());
},
editorProps: {
attributes: {
class:
"prose prose-sm dark:prose-invert max-w-none min-h-[200px] p-4 focus:outline-none",
},
},
});
if (!editor) return null;
return (
<div className="border rounded-lg overflow-hidden">
{editable && <EditorToolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}
Toolbar
"use client";
import type { Editor } from "@tiptap/react";
import { useCallback, useState } from "react";
interface ToolbarProps {
editor: Editor;
}
export function EditorToolbar({ editor }: ToolbarProps) {
return (
<div className="flex flex-wrap items-center gap-0.5 border-b p-1.5 bg-muted/30">
{/* Text formatting */}
<ToolbarGroup>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
label="Bold"
>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
label="Italic"
>
<em>I</em>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive("underline")}
label="Underline"
>
<span className="underline">U</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive("strike")}
label="Strikethrough"
>
<span className="line-through">S</span>
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Headings */}
<ToolbarGroup>
{([1, 2, 3] as const).map((level) => (
<ToolbarButton
key={level}
onClick={() => editor.chain().focus().toggleHeading({ level }).run()}
active={editor.isActive("heading", { level })}
label={`Heading ${level}`}
>
H{level}
</ToolbarButton>
))}
</ToolbarGroup>
<ToolbarDivider />
{/* Lists */}
<ToolbarGroup>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
label="Bullet list"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
label="Ordered list"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive("blockquote")}
label="Blockquote"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Alignment */}
<ToolbarGroup>
{(["left", "center", "right"] as const).map((align) => (
<ToolbarButton
key={align}
onClick={() => editor.chain().focus().setTextAlign(align).run()}
active={editor.isActive({ textAlign: align })}
label={`Align ${align}`}
>
<span className="text-xs capitalize">{align[0].toUpperCase()}</span>
</ToolbarButton>
))}
</ToolbarGroup>
<ToolbarDivider />
{/* Insert */}
<ToolbarGroup>
<LinkButton editor={editor} />
<ImageButton editor={editor} />
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
label="Horizontal rule"
>
<span className="text-xs">HR</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive("code")}
label="Inline code"
>
<span className="font-mono text-xs">{`</>`}</span>
</ToolbarButton>
</ToolbarGroup>
<div className="ml-auto flex gap-0.5">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
label="Undo"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
label="Redo"
>
<svg className="w-4 h-4 rotate-180 -scale-y-100" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</ToolbarButton>
</div>
</div>
);
}
function ToolbarButton({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className={`p-1.5 rounded text-sm min-w-[28px] h-7 flex items-center justify-center ${
active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{children}
</button>
);
}
function ToolbarGroup({ children }: { children: React.ReactNode }) {
return <div className="flex items-center gap-0.5">{children}</div>;
}
function ToolbarDivider() {
return <div className="w-px h-5 bg-border mx-1" />;
}
function LinkButton({ editor }: { editor: Editor }) {
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}, [editor]);
return (
<ToolbarButton onClick={setLink} active={editor.isActive("link")} label="Add link">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
</svg>
</ToolbarButton>
);
}
function ImageButton({ editor }: { editor: Editor }) {
const addImage = useCallback(() => {
const url = window.prompt("Image URL");
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
return (
<ToolbarButton onClick={addImage} label="Add image">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
</svg>
</ToolbarButton>
);
}
Usage
"use client";
import { useState } from "react";
import { RichTextEditor } from "@/components/RichTextEditor";
export function BlogPostForm() {
const [content, setContent] = useState("");
return (
<form className="space-y-4">
<div>
<label className="text-sm font-medium">Title</label>
<input
type="text"
className="w-full border rounded px-3 py-2 mt-1"
placeholder="Post title"
/>
</div>
<div>
<label className="text-sm font-medium">Content</label>
<div className="mt-1">
<RichTextEditor
content={content}
onChange={setContent}
placeholder="Write your post..."
/>
</div>
</div>
<button
type="submit"
className="bg-primary text-primary-foreground px-4 py-2 rounded"
>
Publish
</button>
</form>
);
}
Need a Content Management Solution?
We build custom CMS solutions with rich editing experiences. Contact us to discuss your content needs.