Skip to content
Back to Design Hub

Rich Text Editor Architecture

ContentEditable pitfalls, document models, and why you should use ProseMirror/TipTap not contentEditable directly.

Component PatternsAdvanced

Why

Building a rich text editor on raw contentEditable sounds simple until you hit cursor jumps, broken copy-paste, and unserializable HTML. A document-model editor (ProseMirror/TipTap/Lexical) gives you a schema, transactions, and sane behavior.

Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.

When

Use a real editor for anything beyond a single-line input: comments with bold/italic, articles, docs. For code, use a code editor (Monaco/CodeMirror) instead.

How

1. Pick a framework — TipTap (ProseMirror wrapper, React-friendly) or Lexical (Meta, performant) 2. Schema — define allowed nodes/marks (paragraph, heading, bold, link); reject anything else 3. Toolbar — buttons call editor commands (toggleBold, setLink), disable when command inactive 4. Paste — sanitize HTML to your schema, strip styles/classes, keep only semantic tags 5. Persistence — serialize to JSON (not HTML) for storage; HTML is a render target, not a model 6. Collaboration — yjs or ProseMirror collab plugin for real-time multi-user 7. Extensions — image upload, mention, code block as plugins; never fork the core

Example

import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; function RichEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) { const editor = useEditor({ extensions: [StarterKit], // paragraph, heading, bold, italic, list, code... content: value, onUpdate: ({ editor }) => onChange(editor.getHTML()), editorProps: { attributes: { class: "prose min-h-32 focus:outline-none" }, }, }); return ( <div className="border rounded"> <div className="toolbar"> <button onClick={() => editor?.chain().focus().toggleBold().run()} className={editor?.isActive("bold") ? "bg-gray-200" : ""}>B</button> <button onClick={() => editor?.chain().focus().toggleItalic().run()}>I</button> </div> <EditorContent editor={editor} /> </div> ); }

Common Problems

  • ⚠Storing HTML as the source of truth — can't query/transform, paste injects arbitrary tags
  • ⚠No schema — users paste a whole Word doc with inline styles, layout breaks
  • ⚠Cursor jumps on re-render — editor re-inits on every parent render, loses focus

Best Practices

  • ✓Store JSON, render HTML — the document model is queryable and transformable; HTML is output only
  • ✓Define a strict schema — reject nodes/marks you don't support, paste auto-converts to fit
  • ✓Stabilize the editor instance — useEditor with a stable config, don't recreate on every render

FAQ