Tiptap vs Lexical vs Slate.js vs Quill: Rich Text Editors in React (2026)
TL;DR
Tiptap is the default choice for most React projects in 2026 — it abstracts ProseMirror into a clean, extensible API with a thriving extension ecosystem. Lexical (Meta's editor framework) is the right choice for performance-critical applications and large React Native teams. Slate.js gives you maximum control but requires significant custom code. Quill is legacy — functional but no longer getting meaningful updates.
Key Takeaways
- Tiptap v2: ~1.2M weekly downloads — most popular React rich text editor, ProseMirror-based
- Lexical: ~1.1M weekly downloads — Meta's editor framework, built for Facebook/Messenger/Docs
- Slate.js: ~550K weekly downloads — unopinionated, plugin-first, steeper learning curve
- Quill: ~1.3M weekly downloads — largely legacy installs, limited modern TypeScript support
- Tiptap wins on developer experience and extension ecosystem
- Lexical wins on performance, React Native support, and Meta's production backing
- Don't use Quill for new projects — choose Tiptap or Lexical
Download Trends
| Package | Weekly Downloads | Backing | Bundle Size |
|---|---|---|---|
quill | ~1.3M | Community | ~400KB |
@tiptap/core | ~1.2M | Open-source + enterprise | ~56KB |
@lexical/react | ~1.1M | Meta | ~65KB |
slate | ~550K | Community | ~45KB |
Architecture Overview
| Library | Foundation | Philosophy |
|---|---|---|
| Tiptap | ProseMirror | Opinionated, extensions, React wrapper |
| Lexical | From scratch | Lean core, plugins, framework-agnostic |
| Slate.js | From scratch | Plugin-first, maximum control |
| Quill | Delta format | Simple API, opinionated toolbar |
Tiptap
Tiptap wraps ProseMirror (the most powerful rich text editing engine) with a developer-friendly API. ProseMirror's power without ProseMirror's complexity.
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"
function RichTextEditor() {
const editor = useEditor({
extensions: [
StarterKit, // Heading, Bold, Italic, Lists, Code, Blockquote, etc.
Image.configure({ resizable: true }),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: "text-blue-500 underline" },
}),
Placeholder.configure({
placeholder: "Start writing…",
}),
],
content: "<p>Hello World!</p>",
onUpdate: ({ editor }) => {
console.log(editor.getHTML()) // Or editor.getJSON()
},
})
return (
<div>
{/* Toolbar */}
<div className="toolbar">
<button
onClick={() => editor?.chain().focus().toggleBold().run()}
className={editor?.isActive("bold") ? "is-active" : ""}
>
Bold
</button>
<button
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</button>
</div>
{/* Editor */}
<EditorContent editor={editor} className="editor-content" />
</div>
)
}
Custom extension:
import { Extension, Node } from "@tiptap/core"
// Simple mark extension:
const Highlight = Extension.create({
name: "highlight",
addOptions() {
return { color: "#fef9c3" }
},
addGlobalAttributes() {
return [{
types: ["textStyle"],
attributes: {
color: {
default: null,
parseHTML: (el) => el.style.backgroundColor || null,
renderHTML: (attrs) => attrs.color ? { style: `background-color: ${attrs.color}` } : {},
},
},
}]
},
})
// Node extension (custom block type):
const CalloutNode = Node.create({
name: "callout",
group: "block",
content: "inline*",
parseHTML: () => [{ tag: "div.callout" }],
renderHTML: ({ HTMLAttributes }) => ["div", { class: "callout", ...HTMLAttributes }, 0],
addNodeView() {
return ReactNodeViewRenderer(CalloutComponent) // React component
},
})
Tiptap ecosystem:
- 50+ official extensions (tables, mentions, collaboration, math, etc.)
- Tiptap Cloud — real-time collaborative editing (Hocuspocus/Y.js)
- Pro extensions: AI writing, content browser, export (docx, PDF)
- Used by: Substack, Linear, GitLab, Vercel
Lexical
Lexical was created by Meta as the replacement for Draft.js (deprecated). It powers Facebook comments, Messenger, and internal tools at Meta.
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { ListNode, ListItemNode } from "@lexical/list"
const editorConfig = {
namespace: "MyEditor",
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode],
theme: {
heading: { h1: "text-3xl font-bold", h2: "text-2xl font-semibold" },
text: { bold: "font-bold", italic: "italic", underline: "underline" },
},
onError(error: Error) {
console.error(error)
},
}
function LexicalEditor() {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-wrapper">
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<div className="placeholder">Start writing…</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<AutoFocusPlugin />
<OnChangePlugin
onChange={(editorState) => {
editorState.read(() => {
const root = $getRoot()
const text = root.getTextContent()
console.log(text)
})
}}
/>
</div>
</LexicalComposer>
)
}
Lexical custom node:
import {
DecoratorNode,
LexicalNode,
NodeKey,
SerializedLexicalNode,
} from "lexical"
export class ImageNode extends DecoratorNode<React.ReactElement> {
__src: string
__altText: string
static getType(): string { return "image" }
static clone(node: ImageNode): ImageNode {
return new ImageNode(node.__src, node.__altText, node.__key)
}
constructor(src: string, altText: string, key?: NodeKey) {
super(key)
this.__src = src
this.__altText = altText
}
exportJSON(): SerializedLexicalNode {
return { type: "image", version: 1, src: this.__src, altText: this.__altText }
}
decorate(): React.ReactElement {
return <img src={this.__src} alt={this.__altText} />
}
createDOM(): HTMLElement {
return document.createElement("div")
}
updateDOM(): false { return false }
}
Lexical's architectural advantages:
- Performance: Minimal reconciliation — updates only changed text ranges, not full re-renders
- React Native: Official support — same editor logic across web and mobile
- Accessibility: ARIA attributes and screen reader announcements built-in
- Immutable state: Editor state is immutable, making undo/redo and history trivial
- Plugin composition: No extension inheritance hierarchy — plugins are independent
Slate.js
Slate.js gives you the most control but makes the fewest decisions. The document model is a custom JSON tree that you define completely:
import { createEditor, Descendant, Editor, Transforms } from "slate"
import { Slate, Editable, withReact, useSlate } from "slate-react"
import { withHistory } from "slate-history"
import { useCallback, useMemo } from "react"
// Define your document schema:
type CustomElement = { type: "paragraph" | "heading"; children: CustomText[] }
type CustomText = { text: string; bold?: boolean; italic?: boolean }
// Extend Slate types:
declare module "slate" {
interface CustomTypes {
Element: CustomElement
Text: CustomText
}
}
const initialValue: Descendant[] = [
{ type: "paragraph", children: [{ text: "Hello World!" }] }
]
function SlateEditor() {
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
const renderElement = useCallback(({ attributes, children, element }) => {
switch (element.type) {
case "heading": return <h2 {...attributes}>{children}</h2>
default: return <p {...attributes}>{children}</p>
}
}, [])
const renderLeaf = useCallback(({ attributes, children, leaf }) => (
<span
{...attributes}
style={{
fontWeight: leaf.bold ? "bold" : "normal",
fontStyle: leaf.italic ? "italic" : "normal",
}}
>
{children}
</span>
), [])
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={(event) => {
if (event.key === "b" && event.ctrlKey) {
event.preventDefault()
Editor.addMark(editor, "bold", true)
}
}}
/>
</Slate>
)
}
Slate's strengths:
- Completely custom document schema — model your own content types
- No opinions about how your document looks or works
- Good for domain-specific editors (code editors, form builders, JSON editors)
Slate's weaknesses:
- You build everything from scratch — no built-in toolbar, shortcuts, or common blocks
- Collaboration (Y.js) requires significant plumbing
- Breaking changes between versions have been a persistent issue
Quill
Quill is the legacy option. It was the dominant choice from 2015–2020 and still has the highest download count due to installed base:
import Quill from "quill"
// Simple JavaScript API — no React integration:
const quill = new Quill("#editor", {
theme: "snow",
modules: {
toolbar: [["bold", "italic"], [{ header: [1, 2, false] }], ["link", "image"]]
}
})
// For React, use react-quill (but it has React 18 issues):
// npm install react-quill — WARNING: no React 18 SSR support
Why Quill is legacy:
- TypeScript support added after the fact — not first-class
- No React 18 / Server Components compatibility
- The Delta format is non-standard and hard to map to modern document schemas
- Last major release was 2.0 in 2022 — development pace has slowed
- react-quill is abandoned with known React 18 SSR breakage
Feature Comparison
| Feature | Tiptap | Lexical | Slate.js | Quill |
|---|---|---|---|---|
| React integration | ✅ First-class | ✅ First-class | ✅ First-class | ⚠️ Via react-quill |
| TypeScript | ✅ | ✅ | ✅ | ⚠️ Partial |
| React Native | ❌ | ✅ | ❌ | ❌ |
| Extension system | ✅ Excellent | ✅ Plugin-based | ✅ Plugin-based | ⚠️ Limited |
| Collaboration (Y.js) | ✅ Built-in/Cloud | ✅ Community | ✅ Community | ❌ |
| Custom node views | ✅ React component | ✅ Decorator nodes | ✅ | ❌ |
| Toolbar | ✅ Extension | DIY | DIY | ✅ Built-in |
| Table support | ✅ Extension | ✅ Community | ✅ Community | ⚠️ Limited |
| Markdown shortcuts | ✅ Extension | ✅ Plugin | ✅ | ❌ |
| Floating menus | ✅ BubbleMenu | DIY | DIY | ❌ |
| Image upload | ✅ Extension | ✅ Plugin | ✅ | ⚠️ Basic |
| Output format | HTML/JSON | JSON | JSON | Delta |
| Active development | ✅ | ✅ Meta-backed | ⚠️ Slower | ⚠️ Slow |
| Commercial support | ✅ Tiptap Pro | ❌ | ❌ | ❌ |
When to Use Each
Choose Tiptap if:
- You want the fastest path to a production-quality editor
- You need common features (tables, mentions, collaboration) without building them
- Your team doesn't want to learn ProseMirror internals
- The Tiptap Cloud subscription for collaborative editing is acceptable
Choose Lexical if:
- React Native support is required (same editor on web and mobile)
- Performance is critical at large document scale
- You want Meta's production backing for long-term maintenance
- You prefer the plugin/composability model over extension inheritance
Choose Slate.js if:
- You need a completely custom document model (not standard rich text)
- Building a domain-specific editor (structured content, form builder, code notebook)
- You have the development time to build toolbar and features from scratch
Don't use Quill if:
- Starting a new project in 2026
- You're using React 18+ with SSR
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on official documentation for @tiptap/core v2.x, lexical v0.19.x, slate v0.104.x, and quill v2.0.x. Bundle sizes from bundlephobia for core packages only.