Design a rich text editor
A reusable editing component where people write formatted text with headings, lists, links, mentions, and images. The hard parts are keeping a clean document model separate from the messy browser DOM, handling every kind of input (IME, mobile keyboards, paste) without losing text, and staying fast on every keystroke.
The question
Design a rich text editor component that product teams across the company can drop into their pages: a comment box, a ticket description, a long knowledge-base article. People can make text bold or italic, add headings, lists, quotes, links, @mentions, and images, and undo their changes.
It must work with every keyboard, including Japanese and Chinese input and phone keyboards with autocorrect, handle pasting from Word and Google Docs, and save drafts so nobody loses work. Walk me through how you would build it.
Real-life examples: Notion page editor, Slack and Discord message composer, Medium story editor, GitHub comment box (rich mode), Confluence and Jira description fields
Requirements: what kind of editor are we building?
"Build a rich text editor" can mean a three-button comment box or a full word processor. The two differ by about two years of engineering. So the first five minutes go to questions whose answers change the design.
A rich text editor is an editing area where text carries formatting (bold, headings, links) that the person sees while typing, instead of writing markup like Markdown by hand. The industry name for this is WYSIWYG: what you see is what you get.
The questions below are ordered by how much they move the design. First: who embeds the editor and how big documents get, because that decides how much engineering goes into rendering and storage. Then: which content types must exist, how input works on phones and in other languages, and whether several people edit at once. The table shows the answer this guide assumes and what changes if the answer is different.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who uses the component? | Many product teams. It ships as a package with a small, stable API. | We need a clean props and events API, a plugin system, and a stable saved format. |
| How large do documents get? | Most are under 2,000 words. Knowledge-base articles reach 50,000 words, about 3,000 blocks. | Rendering must touch only changed blocks. Full re-renders are out. |
| What content is allowed? | Paragraphs, headings 1 to 3, bullet and numbered lists, quotes, code blocks, bold, italic, underline, strike, inline code, links, mentions, images, and embeds. | The size of the schema, the toolbar, and the paste rules. |
| Which inputs must work? | Desktop keyboards, IME for Chinese, Japanese, and Korean, Android and iOS keyboards with autocorrect, voice dictation, and paste. | We can't rely on key events alone. The input pipeline is the hardest part. |
| Is it collaborative? | Not in version 1. One person per document, but the design must allow live collaboration later. | Edits are expressed as operations, so a sync layer can be added without a rewrite. |
| How is content saved? | As JSON to the host's API, with drafts kept locally. | JSON is the source of truth. HTML and Markdown are exports. |
| Which browsers? | The last two versions of Chrome, Edge, Firefox, and Safari, on desktop and mobile. | We can use the beforeinput event and the Input Events spec, with fallbacks. |
Functional requirements
- Type and edit text with a caret and selections, exactly like a native text field: arrows, Home and End, word jumps, double-click to select a word, drag to select.
- Apply inline marks (bold, italic, underline, strike, inline code, link) and block types (paragraph, heading, list item, quote, code block) from a toolbar, from keyboard shortcuts, and from Markdown-like typing such as
##for a heading. - Insert links, @mentions with a suggestion popup, images (upload or paste), and embeds such as a video link.
- Undo and redo, with sensible grouping: one undo removes a typed word or phrase, not a single letter.
- Paste from Word, Google Docs, web pages, and plain text, keeping the formatting the editor supports and dropping the rest.
- Save the content as JSON, export HTML and Markdown, and keep a local draft so a crash or a closed tab loses nothing.
Non-functional requirements
- Input latency: a keystroke shows on screen in under 16 ms on a mid-range laptop (one frame at 60 Hz) and well under 50 ms on a mid-range phone. The page's Interaction to Next Paint (INP, the Core Web Vital that measures how fast the page reacts to input) stays under 200 ms at the 75th percentile.
- Correctness: no lost or doubled characters, ever, for any input method. Losing text is the one bug people never forgive in an editor.
- Safety: pasted or loaded content can never run script. The editor only produces markup it knows.
- Accessibility: works with a keyboard alone and with screen readers (VoiceOver, NVDA, JAWS, TalkBack). Toolbar state is announced.
- Size: the core loads in under about 60 KB gzipped, with heavy features such as tables or code highlighting loaded on demand.
- Extensibility: teams add a node type (say, a callout box) without forking the editor.
Out of scope, and why
- Live multi-person editing: we design the hooks for it (operations, positions that map through changes), but the merge algorithms belong to a different problem. The Google Docs guide covers them in depth.
- Page layout and printing: page breaks, headers and footers, and columns are a word-processor problem, not a component problem.
- Comments and suggestions mode: they ride on the same model (as marks or side ranges), so we mention where they would plug in but don't design them.
Check your understanding · Exercise 1
Which answers to scoping questions would change this design the most? Select all that apply.
What makes this hard: contenteditable is a trap
Every rich text editor on the web starts from one browser feature: contenteditable. Put contenteditable="true" on a div and the browser turns it into an editing surface. The caret blinks, typing inserts text, arrows move, the spell checker underlines words, IMEs work, and the phone keyboard opens. That is a huge amount of free work, and it is why almost nobody draws text on a canvas.
The naive design stops there. Make a div editable, wire the Bold button to document.execCommand("bold"), and save element.innerHTML when the person clicks Save. A first demo takes an afternoon, and then the bug reports arrive. Here is what goes wrong, and why each failure is built into the approach rather than an easy fix.
Five ways the naive editor breaks
- The output is whatever the browser felt like.
execCommandis marked deprecated on MDN, its only spec is an unofficial draft that is no longer maintained, and engines differ in the markup they produce. Bold might come out asb,strong, or aspanwithfont-weight, depending on the browser, its settings, and where the text came from. Enter used to insert adivin Chrome and Safari, a barebrin Firefox before version 60, and apin old Internet Explorer. Engines agree more today, but the result still depends on the surrounding markup. The saved HTML becomes a mix of all of them, and the same document looks different depending on who last edited it. - The saved format is the DOM. Paste a paragraph from a web page and the editor now holds that page's classes, inline styles,
fonttags, and maybe atableyou never meant to support. SavinginnerHTMLstores all of it. Every reader of that HTML (email, mobile app, search index) must cope with markup nobody chose. - It is a security hole. Pasted HTML can contain
imgtags withonerrorhandlers orjavascript:links. If you store and later renderinnerHTML, you have built a stored XSS (cross-site scripting) bug. Sanitizing after the fact is fragile because you're cleaning arbitrary HTML instead of producing known HTML. - Undo is the browser's, not yours. The built-in undo stack only knows about changes the browser made itself. The moment your code changes the DOM directly (inserting a mention chip, fixing up a list), the browser's history and the document disagree, and Ctrl+Z jumps to strange states or does nothing.
- Nothing has meaning. To answer "is the selection bold?" or "how many words?" or "where is the mention of Priya?", you walk a DOM tree that could have any shape. A heading could be an
h2, or adivwith a big font size pasted from somewhere. Features built on top (collaboration, comments, export) all inherit that chaos.
strong.The common thread: the DOM is a view, a thing the browser draws, and the naive editor treats it as the data. Every serious editor today flips this around. It keeps its own document model as the source of truth, lets the browser handle the caret and the input methods inside contenteditable, and then translates every change into an edit on its model, which it renders back to the DOM. That translation layer is the core of the design, and most of this guide.
Decision What is the editing surface, and what is the source of truth?
What we pick: Pick the third option: contenteditable for input, our own model as the truth. It keeps everything the browser does well (caret, IME, spell check, screen reader support) and removes everything it does badly (choosing the markup). It is also what ProseMirror, Lexical, Slate, and Tiptap all do, each in its own way. Canvas rendering is right for Google Docs with its pages, and wrong for a component other teams embed.
The challenges that drive every later decision
With that choice made, five properties of the problem shape the rest of the guide. Name them early in an interview; they are your agenda.
| Property | What it means | Where we handle it |
|---|---|---|
| Two trees that must agree | The model says one thing, the DOM shows another thing, and the browser's selection lives in the DOM. Every change must keep them in step. | Document model, rendering |
| Input you don't control | IMEs, autocorrect, dictation, and spell check change the DOM directly. Some of these changes can't be cancelled. | Input pipeline |
| Edits as data | Undo, autosave, and future collaboration all need changes as small, replayable records, not DOM snapshots. | Transactions and history |
| Hostile content | Paste and drop bring in HTML from anywhere, including script. | Paste and drop |
| Per-keystroke budget | All of the above must run in a few milliseconds per key, on a 50,000-word document. | Rendering, performance |
Check your understanding · Exercise 2
A team saves innerHTML from a contenteditable div and renders it later on a profile page. Which problems does this create? Select all that apply.
The document model: a tree the browser doesn't own
The model is a plain data structure that describes the document's meaning, with no DOM in it. It must answer three questions fast: what content is here, where is the caret, and is this content allowed. Get the shape right and every later feature (toolbar state, undo, paste, export, collaboration) becomes a function over clean data.
Blocks, inline content, and marks
Documents have two levels. Blocks stack vertically: paragraphs, headings, list items, quotes, code blocks, images. Some blocks contain other blocks (a list contains list items, a quote contains paragraphs), so blocks form a tree. Inside a text block is inline content: runs of text, plus inline nodes such as a mention chip or an inline image. Formatting such as bold or a link is a mark: a label attached to a run of text, not a wrapper element.
Why marks instead of nested elements? In HTML, "Hello bold _both_ world" can be written as b containing i, or i inside b in a different order, or as two separate b tags next to each other. All mean the same.
If the model stores text runs where each run carries a set of marks, there is exactly one way to write it: Hello with no marks, bold with bold, both with bold and italic, world with none. Adjacent runs with the same marks are merged. One meaning gets one shape, which makes comparing, diffing, and merging simple.
type Mark =
| { type: "bold" } | { type: "italic" } | { type: "code" }
| { type: "link"; href: string };
type Inline =
| { type: "text"; text: string; marks: Mark[] }
| { type: "mention"; userId: string; label: string }; // atom: one unit
type Block =
| { type: "paragraph"; id: string; content: Inline[] }
| { type: "heading"; id: string; level: 1 | 2 | 3; content: Inline[] }
| { type: "list"; id: string; ordered: boolean; items: Block[] }
| { type: "image"; id: string; src: string; alt: string };
type Doc = { type: "doc"; version: number; blocks: Block[] }; // schema version
contenteditable="false" span, and the bold mark becomes a strong.Positions and selections live in the model too
The browser describes the caret as a DOM node plus an offset inside it. That is useless as stored data, because the DOM nodes are replaced whenever we re-render. So the model has its own way to point at a spot. There are two common styles.
- Paths: an array of child indexes plus a character offset, like
[2, 0]offset5: block 2, its first text run, after the fifth character. Slate uses paths. They are easy to read, but they change whenever something earlier in the tree is inserted or removed. - Flat integer positions: walk the document and count. Each character counts one, each block boundary (start and end) counts one, each atom such as a mention counts one. Then any spot is a single number. ProseMirror uses this. A number is cheap to store and easy to shift: if three characters are inserted at position 10, every position after 10 moves by three.
A selection is then two positions: the anchor (where the person started selecting) and the head (where they are now). When the two are equal the selection is collapsed, which is just a caret. Keeping anchor and head separate, rather than start and end, matters: Shift plus arrow extends from the head, and a backwards selection must stay backwards. Besides text selections, editors need a node selection for picking a whole image or embed as one unit.
Positions must also survive edits. When a change is applied, the editor produces a mapping: a function that tells where an old position ends up in the new document. Selections, comment ranges, and later remote cursors are all moved through that mapping. It's a small idea that makes collaboration possible later without changing the model.
The schema: what is allowed where
A schema is the list of node types and mark types, plus the rules for how they nest: a list contains only list items, a heading contains only inline content, a code block allows no marks, an image has a required src and an alt. Every change is checked against it. Content that breaks the rules is either fixed up (a stray text run at the top level is wrapped in a paragraph) or rejected.
The schema is what makes the editor predictable. Paste can only produce allowed nodes. A team that embeds the comment box can pass a smaller schema (no headings, no images), and the same editor core enforces it. Loading a saved document from an older version runs it through the same rules, so bad data gets normalized instead of crashing the view.
Decision How should the model be shaped?
What we pick: A tree of blocks with marked inline runs and a schema. It matches the content we need (nested lists, quotes, embeds), gives each block a stable id for keyed rendering, and has one canonical shape per meaning. We count positions as flat integers internally and expose block ids to the host for things like deep links.
How the well-known libraries approach it
You don't need to have used these libraries in an interview, but knowing their shapes shows you understand the space. The descriptions below are simplified.
| Library | Model | How changes flow | Rendering |
|---|---|---|---|
| ProseMirror | Immutable tree, strict schema, marks on text, flat integer positions. | Transactions made of steps. Each step comes with a position map. | Own view layer that patches the DOM and reads DOM changes back with a MutationObserver. |
| Tiptap | ProseMirror underneath. | Same as ProseMirror, with a friendlier extension and command API. | Headless: you bring the UI. Has bindings for React and Vue. |
| Lexical (Meta) | Tree of node objects with keys. Text formatting is stored as flags on text nodes, while links are element nodes that wrap text. The tree lives in an editor state changed only inside editor.update() calls. | Updates work on a pending copy of the state. Updates in the same tick are batched and committed together, and commands and listeners react to them. | Own reconciler that patches only the DOM belonging to changed nodes. |
| Slate | Plain JSON tree: elements with children, leaves with text and format properties. Paths address nodes. | Low-level operations such as insert_text and split_node. | React components you write for each element and leaf. |
Check your understanding · Exercise 3
Why do editors store bold and italic as marks on text runs, rather than as nested elements like HTML? Select all that apply.
The input pipeline: turning keystrokes into model changes
This is the hardest part of the editor and the place most homegrown editors fail. The browser lets people produce text in many ways: a key press, an IME (input method editor, the tool that turns keystrokes into Chinese, Japanese, or Korean characters), a phone keyboard's autocorrect, swipe typing, voice dictation, the spell checker's right-click menu, drag and drop, paste. The editor must catch every one of them and turn it into a change on the model.
Why listening to keydown is not enough
The naive approach handles keydown: if the key is a letter, insert it into the model, call preventDefault(), and re-render. It works on a desktop with an English keyboard, and breaks everywhere else. During IME composition, keydown usually reports key as Process and keyCode 229, and the real characters appear later. On Android with Gboard and similar keyboards, many keys arrive as keydown with key set to Unidentified and keyCode 229, so you can't tell what was typed.
Other input has no useful key event at all. Autocorrect replaces a whole word, dictation inserts whole sentences, and a spell-check fix from the context menu involves no key.
So keys are the wrong level. What we want is the browser's statement of intent: "the person wants to insert this text here" or "delete the word before the caret", whatever device produced it.
beforeinput and inputType
The Input Events spec gives exactly that. Before the browser changes an editable element, it fires beforeinput, supported in every major engine since Firefox 87 shipped it in 2021. The event has an inputType string that names the intent and getTargetRanges(), which returns the static ranges the change will affect. The text travels in data for typing, and in dataTransfer for paste, drop, and (in contenteditable) replacement text.
Almost every input type is cancelable; the spec's main exception is insertCompositionText. For a cancelable event, the editor calls preventDefault(), applies the change to its model, and renders the result itself, so the browser never touches the DOM. After a change that was not cancelled, the browser fires input.
| inputType | Produced by | What the editor does |
|---|---|---|
insertText | Typing a character, some autocomplete | Insert data at the selection, with the current stored marks. |
insertReplacementText | A spell-check fix, and autocorrect in some browsers | Replace the target range with the new text, keeping marks. |
insertParagraph | Enter | Split the current block, or exit an empty list item. |
insertLineBreak | Shift+Enter | Insert a hard break node inside the block. |
deleteContentBackward | Backspace | Delete one grapheme back, or join with the previous block at a block start. |
deleteWordBackward | Ctrl+Backspace, Option+Backspace | Delete to the previous word boundary. |
insertFromPaste, insertFromDrop | Paste and drop | Run the paste pipeline (see the paste chapter). |
formatBold, formatItalic | Browser shortcuts or menus | Toggle the mark through our own command. |
historyUndo, historyRedo | Undo in the browser's Edit or context menu, or Ctrl+Z when no keydown handler caught it | Run our own undo, not the browser's. |
insertCompositionText | IME composition | Not cancelable. Handled by the composition path below. |
beforeinput. Saving happens later and never blocks typing.One subtle rule hides in the Backspace row: delete one grapheme, not one UTF-16 code unit. A family emoji or a flag is several code points joined together, and deleting half of it leaves broken characters. Intl.Segmenter with granularity: "grapheme" finds the right boundary. The same applies to arrow keys, but the browser already moves the caret by graphemes, which is another reason to let it handle caret movement.
IME composition: when the browser must own the DOM
To type the Japanese word for "thank you", a person types the romaji arigatou on a Latin keyboard. As they type, the IME shows underlined hiragana that keeps changing, opens a candidate window, and when they press Enter or Space it commits the final characters. During that time the text is in composition: provisional, owned by the IME, drawn with an underline.
The events are compositionstart, then for each change a compositionupdate plus beforeinput and input of type insertCompositionText, then compositionend. The key fact: the spec makes beforeinput during composition not cancelable, because the IME writes into the DOM directly.
If the editor re-renders the paragraph during composition, it replaces the text node the IME is writing into, and the composition breaks: characters double, the candidate window closes, or text is lost. Browsers also differ in event order at the end of a composition. Chrome and Firefox fire the Enter keydown that commits the text before compositionend, with isComposing true. Safari fires compositionend first, then a keydown with isComposing false and keyCode 229, so code that only checks isComposing splits the paragraph by mistake.
compositionend.- While composing, don't touch the DOM of the block that holds the selection. Pause re-rendering for that block. Remote changes and autosave can wait a moment.
- At
compositionend, read what the DOM now contains in that block (or the event'sdata), diff it against the model, and turn the difference into one normal transaction. Then re-render to normalize. - Let
keydownpass through whenisComposingis true orkeyCodeis 229 (the Safari case above). Enter during composition commits the candidate; it must not split the paragraph. - Keep a fallback: a
MutationObserveron the editor root catches any DOM change the editor didn't make (an extension, a browser quirk) and reconciles it back into the model, or reverts it.
That last point is a design principle worth saying out loud. The editor has two ways in: intercept (cancel beforeinput and apply to the model) when possible, and observe (let the browser change the DOM, then read it back) when not. ProseMirror leans on observing, letting the browser insert typed text and reading the DOM change back; Lexical and Slate lean on intercepting beforeinput. All of them need both paths.
Mobile keyboards and autocorrect
Phone keyboards are the second hard case. Android keyboards often treat the current word as a composition, even for English, so every letter arrives as insertCompositionText. Autocorrect should arrive as insertReplacementText with a target range covering the corrected word. WebKit bug reports show Safari, especially on iOS, sending a plain insertText instead, so the editor must also spot replacements by comparing text.
Both platforms' keyboards read the text around the caret to suggest words. If the editor rewrites that text (for example, re-creating text nodes on every keystroke), suggestions reset and the keyboard can flicker or re-capitalize. Keep the DOM text node around the caret stable across keystrokes: update its text instead of replacing the node. Test on real devices, because simulators and emulators don't always run the same keyboards.
Keeping the selection in step
The person moves the caret with arrows, the mouse, and touch handles, and the browser updates the DOM selection. The editor listens to selectionchange on document, reads document.getSelection(), converts its anchor and focus (DOM node plus offset) into model positions, and stores that as the model selection. After the editor renders a change, it does the reverse: converts the model selection to DOM points and calls selection.setBaseAndExtent(). To avoid a loop, it ignores the selectionchange caused by its own update.
The DOM-to-model conversion needs every rendered node to know which model node it came from. A common trick is a WeakMap from DOM node to model node or block id, filled in during rendering. Offsets inside text are the same in both, as long as the editor never renders extra characters (such as a zero-width space used to give empty blocks a caret spot) without accounting for them.
Check your understanding · Exercise 4
A Japanese user types in your editor. Every few words a character appears twice. Which causes are likely? Select all that apply.
Transactions and undo: edits as data
Every change to the document, whatever caused it, goes through one door: a transaction. A transaction is a small record that says how to get from the old document to the new one. It holds a list of steps (insert text at 14, add the bold mark from 20 to 31, split the block at 40), the selection after the change, and some metadata: was it typing, a paste, an undo, a remote change? Applying a transaction produces a new immutable document plus a position mapping.
Why not simply mutate the model in place? Because three features need the change itself, not only the result. Undo needs to invert it, autosave and collaboration need to send it, and plugins (word count, mention detection, the toolbar) need to know what changed so they don't rescan the whole document. A transaction is the shared language for all of them.
type Step =
| { kind: "insertText"; pos: number; text: string; marks: Mark[] }
| { kind: "deleteRange"; from: number; to: number }
| { kind: "addMark"; from: number; to: number; mark: Mark }
| { kind: "removeMark"; from: number; to: number; mark: Mark }
| { kind: "splitBlock"; pos: number; newType?: string }
| { kind: "setBlockType"; pos: number; type: string; attrs?: object };
type Transaction = {
steps: Step[];
selectionAfter: { anchor: number; head: number };
meta: { origin: "input" | "paste" | "command" | "history" | "remote"; time: number };
addToHistory: boolean;
};
// Each applied step returns its inverse, computed against the old doc.
// deleteRange keeps the removed slice so its inverse can re-insert it.
Note the comment at the bottom: a step can only be inverted if it remembers what it destroyed. deleteRange on its own doesn't know which text it removed. So when it is applied, the editor stores the removed slice (the content, with its marks and block structure) alongside it. The inverse of that step is "insert this slice at from".
Undo and redo stacks
The history keeps two stacks. Each entry is a group of inverted steps plus the selection from before the change. Undo pops the top group, applies its steps as a new transaction (marked origin: "history" so it isn't recorded again as a fresh change), restores the saved selection, and pushes the forward steps onto the redo stack. Any new edit clears the redo stack.
Grouping: what does one undo remove?
If each keystroke were its own entry, undoing a sentence would take 60 presses of Ctrl+Z. If the whole session were one entry, one undo would wipe an hour of work. People expect undo to remove roughly one burst of typing. The usual rules:
- Time: consecutive typing transactions join the current group if they arrive within a short gap. ProseMirror's history plugin uses 500 ms by default (
newGroupDelay). A pause longer than that starts a new group. - Kind: switching from typing to deleting, or from typing to formatting, starts a new group. Paste, drop, and toolbar commands are always their own group.
- Adjacency: typing that jumps to a different spot in the document starts a new group, even within the time window.
- Explicit breaks: some editors also close the group at word boundaries, such as a space after a word, so undo removes a word at a time.
newGroupDelay (500 ms here) or a change of kind, like typing then formatting, closes the group. One Ctrl+Z removes one group, not one letter.Restoring the selection
Undo that changes the text but leaves the caret somewhere random feels broken. Each history group stores the selection from before its first transaction. Undo restores it, so the caret jumps back to where the person was when they made the change, and the toolbar updates to match. Redo restores the selection from after the group, and the editor scrolls the restored selection into view.
Limits and memory
History grows forever if you let it. Each group holds steps and removed slices, which are small for typing and large for a big paste or a delete-all. Cap the history by count (ProseMirror defaults to 100 groups with its depth option) and drop the oldest groups first. History lives in memory only and is not saved with the document: reloading the page starts with an empty undo stack, which matches what people expect from most web editors.
Decision How should undo be stored?
What we pick: Inverted steps per group. Snapshots would work today for one person, but the brief asks us not to block collaboration, and undo that erases a colleague's paragraph is a serious bug. Steps plus mappings is the shape ProseMirror's history and the Yjs UndoManager both follow. The google-docs guide and the undo-redo problem go deeper on collaborative undo.
Check your understanding · Exercise 5
A person types "Hello", waits two seconds, types " world", then selects everything and presses Ctrl+B. They then press Ctrl+Z once. With the grouping rules above, what should happen? Select all that apply.
Rendering: from model back to the DOM, one block at a time
After a transaction, the new model must appear on screen. The naive way is to throw away the editor's DOM and build it again from the model on every keystroke. On a 200-word comment that costs well under a millisecond and nobody notices.
On a 50,000-word article with 3,000 blocks, it means creating tens of thousands of DOM nodes per key, then a full style and layout pass, so typing can lag by hundreds of milliseconds, worse on a phone. It also destroys the text node the caret sits in, which resets the phone keyboard's suggestions and breaks any IME composition in progress.
So the renderer must change only what changed, and keep untouched DOM nodes exactly as they were, the same objects, not equal copies. This is reconciliation: compare the old and new model, find the differences, and patch the DOM.
Why an immutable model makes this cheap
When a transaction changes one paragraph, an immutable model creates a new object for that paragraph and new objects for each of its ancestors up to the root. Every other block is the very same object as before.
So the renderer walks the new block list and, for each block, asks one question: is it the same object (===) as last time? If yes, it skips the block, because its DOM is already right. On a typical keystroke that check rules out all but one block, at a tiny cost per block. This is the same idea React uses with memo and props compared by reference.
newBlock === oldBlock, its DOM is already right. Stable block ids keep that true when blocks are inserted above.Keyed blocks
Comparing by index fails as soon as blocks move. Insert a paragraph at the top and every block's index shifts by one, so an index-based diff thinks every block changed and rewrites the whole document. Give each block a stable key (the id field in our model, generated once when the block is created and kept through edits), and the renderer can tell "one block was inserted, the rest moved down" from "every block changed". It inserts one DOM node and leaves the others alone.
Keys also help beyond rendering. Comments can anchor to a block id, a "copy link to heading" feature uses the heading's id, and analytics can count edits per block. When a block is split by Enter, the first half keeps the old id and the second half gets a new one, so the caret's block, the one being split, keeps its DOM node.
Inside a block: patching text
Within the changed paragraph, the renderer compares old and new inline runs. If the runs have the same marks and only the text of one run changed (the common case: typing a letter), it sets that text node's data in place. The node keeps its identity, the caret stays put, and phone keyboards keep their suggestions. Only when marks change (bold applied to the middle of a run) does it rebuild the run's elements, and only for that paragraph.
There's one more twist: when the editor let the browser make the change itself (the observe path, or a composition), the DOM is already correct. The renderer must notice that the DOM already matches the new model and do nothing. Rewriting identical text would still reset the IME and the keyboard.
Decision How should the model be rendered to the DOM?
What we pick: Our own reconciler for text and marks, with framework islands for rich nodes. Typing is the hot path and IME safety needs exact control over text nodes, so we don't hand that to a general-purpose diff. ProseMirror and Lexical both reconcile with their own code for this reason. Slate shows a React-first renderer can work, at the cost of fighting React around composition.
Very large documents
Skipping unchanged blocks fixes the per-keystroke cost, but the first render of 3,000 blocks still costs: a rough estimate is 300 to 600 ms of DOM creation and layout on a mid-range phone, so measure your own. Options, from cheap to drastic:
content-visibility: autoon each block, withcontain-intrinsic-sizegiving an estimated height. The browser skips layout and paint for blocks far off screen while keeping them in the DOM, so find-in-page, selection, and screen readers still work. This is the first thing to try.- Render in chunks: render the first screen of blocks immediately, then the rest in slices of about 100 blocks during idle time, so the page responds within the first frame.
- Windowing (virtualization): only keep the visible blocks in the DOM. It cuts memory and node count, but it breaks native find-in-page, select-all across the document, and screen reader navigation, and the selection can't extend into blocks that don't exist. Editors that do this need their own find and selection handling. Too costly for a reusable component unless documents get much larger than ours.
Check your understanding · Exercise 6
Your editor gives each block an index-based key. A user presses Enter at the start of the first paragraph of a 2,000-block document. What happens? Select all that apply.
Paste and drop: letting outside content in safely
People paste constantly: a paragraph from Google Docs, a table from Word, a quote from a web page, a screenshot, a URL. Paste is where the outside world's HTML meets our strict model, and where most editor security bugs live.
The rule that keeps both under control: pasted HTML is never inserted into the DOM. It's parsed, converted into model nodes allowed by our schema, and inserted as an ordinary transaction. Whatever the schema doesn't know is dropped or turned into plain text.
What the clipboard actually holds
A copy puts several flavors on the clipboard at once. In the paste event, event.clipboardData exposes them: getData("text/html") for the formatted version, getData("text/plain") for plain text, and files for images such as screenshots. Each source fills them differently, and each has quirks the converter must know. The markup Word and Google Docs produce is not documented and changes between versions, so treat the table below as what these apps commonly produce today, and keep test fixtures of real pastes.
| Source | What text/html looks like | What to do |
|---|---|---|
| Microsoft Word (desktop) | A full HTML document with mso- styles, o:p elements, conditional comments, and class names like MsoNormal. Lists are often paragraphs with special styles, not ul and li. | Strip comments and Office elements, detect Word's list paragraphs and rebuild real lists, read bold and italic from b, i, and styles. |
| Google Docs | The content is wrapped in an element with an id starting docs-internal-guid, and the wrapper is often a b with font-weight: normal. Real bold is a span with font-weight: 700. | Ignore the wrapper's fake bold. Map font-weight 600 and above to bold, font-style: italic to italic. |
| Web pages | Anything: div soup, classes, inline styles, script and style tags, tracking pixels, relative links. | Keep only structure the schema knows. Resolve or drop relative URLs. Drop every attribute except a few on an allowlist. |
| Plain text editors, terminals | No HTML at all. | Split text/plain on blank lines into paragraphs and keep single newlines as line breaks. |
| Screenshots | No HTML, one image in files. | Insert an image node with a local preview, upload in the background. |
The pipeline, step by step
- Catch the
pasteevent (orbeforeinputwithinsertFromPaste) and callpreventDefault(), so the browser inserts nothing. Read the data from the event'sclipboardData, which needs no permission. The asyncnavigator.clipboard.read()is for custom Paste buttons, and it asks for a permission or a user confirmation depending on the browser. - If there are image files and no HTML, go to the image path. Otherwise read
text/html, and fall back totext/plainif it's empty. - Parse the HTML with
DOMParserinto a separate, inert document. Scripts don't run there and the nodes never touch the page. - Run source-specific cleanup rules (the Word and Google Docs fixes above).
- Walk the parsed tree with the schema's parse rules: each rule says which elements and styles map to which node or mark (
h1toh3become headings,strong,b, and heavyfont-weightbecome bold,awith anhttp,https, ormailtohref becomes a link). Anything unmatched contributes only its text. - Fit the result into the cursor's context. Pasting into a code block keeps plain text only. Pasting a heading into the middle of a paragraph splits it. Then run schema validation and normalization on the slice.
- Insert the slice as one transaction with
origin: "paste", which is its own undo group.
Security: why the schema is the sanitizer
The usual XSS payloads in pasted HTML are script tags, event handler attributes such as onerror and onclick, javascript: URLs in links, iframe and object tags, and SVG with embedded script. An allowlist schema defeats all of them by construction: there is no node type for script, no attribute for onerror, and the link mark's parse rule only accepts a URL whose protocol is http:, https:, or mailto:. Nothing that isn't explicitly modelled gets through. This is far stronger than a blocklist that tries to remove known bad things.
Defense in depth still matters, because content also arrives from the API (a document saved by an old client, or by a bug). So the schema check runs again on load, the server validates the JSON against the same schema on save, and HTML export escapes text and re-checks URLs. If the host app ever renders exported HTML outside the editor, it should also run it through a sanitizer such as DOMPurify and serve a Content Security Policy. The XSS lesson covers those layers.
Images and drop
A pasted screenshot or a dropped file goes through the same steps, with one long-running part: the upload. The editor inserts an image node right away with a temporary src from URL.createObjectURL(file) and an uploadId, so the person sees the image instantly and keeps typing. A separate upload manager sends the file to the host's upload endpoint.
When the upload finishes, the editor dispatches a transaction that swaps the temporary src for the real URL, marked addToHistory: false so undo doesn't revert to a blob URL. If the upload fails, the node shows an error state with a retry button. If the document is saved while an upload is still running, the serializer must not write the blob URL; it either waits or saves a placeholder.
addToHistory: false, so undo never brings back a blob URL.Drop has one extra decision: where. insertFromDrop provides target ranges, and otherwise document.caretPositionFromPoint converts the drop coordinates into a DOM position, which the editor maps to a model position. It is in every major engine since Safari 26.2 added it in late 2025, so keep the older, non-standard caretRangeFromPoint as a fallback for older Safari. Dragging content within the editor is a move: one transaction that deletes the source range and inserts at the mapped target.
Think first
A person pastes a bare YouTube URL into an empty paragraph. What should the editor do, and how should undo behave?
Paste it as a link first: that's the faithful result. Then, because the paragraph holds only that URL and it matches a known embed provider, offer to turn it into an embed node, either automatically or through a small "Embed video?" prompt.
Make the conversion its own transaction right after the paste. Then one undo turns the embed back into the plain link, and a second undo removes the paste. People who wanted the link and not the embed get there with one keystroke.
Check your understanding · Exercise 7
Which paste handling choices are safe and correct? Select all that apply.
Toolbar state, links, mentions, and embeds
The toolbar is what people see most, and it has to tell the truth: Bold looks pressed when the caret is in bold text, the block menu says "Heading 2" inside a heading, and Link is disabled inside a code block. With a model, all of this is a pure function of the document and the selection. Without one, editors call document.queryCommandState("bold"), which is deprecated alongside execCommand and reports what the DOM looks like, not what your model means.
Deriving toolbar state from the selection
- Mark active (collapsed caret): use the stored marks if set (the person pressed Ctrl+B with nothing selected, so the next typed text will be bold), otherwise the marks of the text just before the caret. That's how native word processors behave.
- Mark active (range): active only if every text run in the range has it. Bold on half the selection shows as not pressed, and pressing it bolds the whole range.
- Block type: the type of the block holding the selection head. If the range spans blocks of different types, show a mixed state.
- Enabled: ask the schema. If the current block allows no marks (code block), Bold is disabled. If the command can't apply here (unindent at the top level), the button is disabled.
Compute this once per transaction or selection change, not once per button, and compare against the last result before updating the toolbar. A toolbar with 15 buttons that each scan the selection on every keystroke is a quiet source of input lag in large documents. When the selection spans thousands of blocks (after select-all), stop scanning early: one run without bold is enough to say "not active".
Commands: one function behind every way of doing something
Bold can come from the toolbar, from Ctrl+B or Cmd+B, from a formatBold input event, from Markdown-style typing (**word**), or from a slash menu. All of them call the same command: a function that takes the editor state and returns a transaction, or nothing if it can't apply. The toolbar uses the same command with a dry run to decide whether the button is enabled. That way, the button state and the behavior can never disagree.
type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean;
const toggleBold: Command = (state, dispatch) => {
const { from, to, empty } = state.selection;
if (!state.schema.allowsMark(state.blockAt(from), "bold")) return false;
if (!dispatch) return true; // dry run: the toolbar asks "can I?"
if (empty) {
dispatch(state.tr.toggleStoredMark({ type: "bold" }));
} else if (state.rangeHasMark(from, to, "bold")) {
dispatch(state.tr.removeMark(from, to, { type: "bold" }));
} else {
dispatch(state.tr.addMark(from, to, { type: "bold" }));
}
return true;
};
Links
A link is a mark with an href attribute, so it can cover bold text or part of a word. Creating one opens a small popover anchored to the selection (range.getBoundingClientRect() gives its position), and the URL is checked against the same protocol allowlist as paste.
Typing at the end of a link should not extend the link: the link mark is non-inclusive, unlike bold, which is inclusive. That single flag in the schema is the difference between a natural editor and one where everything typed after a link becomes part of it.
Mentions: an atom with a lookup
Typing @ followed by letters opens a suggestion list. Behind it, an input rule watches transactions: when the text before the caret matches @ plus word characters at a word start, it records a suggestion range and asks the host for matches (debounced by about 150 ms, with the previous request cancelled through an AbortController). Arrow keys move through the list while focus stays in the editor, and aria-activedescendant (borrowed from the combobox pattern) lets screen readers announce the active option. Enter replaces the range with a mention node.
The mention is an atom: an inline node with no editable content inside, which counts as one position. The caret jumps over it, Backspace deletes it whole, and its stored data is the user id plus a display label captured at insert time. Storing the id, not just the name, means notifications, permission checks, and renames work. The autocomplete and mention-input problems go deeper into the suggestion list itself.
Images and embeds: node views
Images, videos, and other embeds are block atoms rendered by a node view: a small component that owns that node's DOM. The node view is marked contenteditable="false" so the browser treats it as one unit, and it can contain its own interactive UI (a caption field, a resize handle, an alt text button).
Embeds from other sites render inside a sandboxed iframe with an allowlist of providers, so a pasted URL can't load arbitrary pages. Node views are the extension point teams use for their own blocks, such as a callout or a status chip.
Check your understanding · Exercise 8
The caret sits right after a bold word with nothing selected, and the person has not pressed any shortcut. What should the toolbar and the next keystroke do? Select all that apply.
Architecture: the parts and their boundaries
We now have all the pieces. This chapter puts them in boxes, says what each owns, and explains why the lines are where they are. The guiding rule: the core knows nothing about React, the network, or the host app. It is a state machine that turns transactions into new states, and everything else plugs into it.
| Part | Owns | Why it's separate |
|---|---|---|
| Schema | Node and mark types, nesting rules, parse rules (HTML to model) and serialize rules (model to HTML). | Hosts pass a different schema per use (comment box versus article). Shared by client, server validation, and export. |
| Editor state | The current document, the selection, stored marks, and plugin state. Immutable: each transaction makes a new state. | One source of truth. Easy to snapshot, test, and compare by reference. |
| Transaction engine | Applying steps, producing mappings and inverses, enforcing the schema. | Every change goes through one door, so history, autosave, and collaboration see everything. |
| Input controller | beforeinput, keyboard shortcuts, composition handling, the MutationObserver fallback, selectionchange. | All browser quirks live here. The rest of the editor never reads DOM events. |
| View (reconciler) | Rendering state to the DOM by block key, mapping DOM points to positions, node views. | Hot path for performance. Isolating it lets us optimize without touching logic. |
| Plugins | History, input rules (Markdown shortcuts, mentions), paste handling, placeholder, word count, collaboration binding. | Features are optional and tree-shakable. Teams add their own. |
| Toolbar and menus | Buttons, block menu, link popover, mention list, slash menu. Built from commands plus derived state. | Hosts replace the UI without touching the core (a headless design, as Tiptap does). |
| Persistence adapter | Autosave timer, local drafts in IndexedDB, calls to the host's save function. | Storage differs by host. The core must not know about networks. |
| Upload manager | Image uploads with progress, retries, and cancel. | Long-running work outside the transaction cycle. |
The update cycle
Every change, from any source, follows the same cycle, and the cycle runs synchronously within the input event so the screen updates in the same frame:
- Something produces a transaction: the input controller (typing), a command (toolbar), a plugin (an input rule), the upload manager (image URL ready), or later the collaboration binding (remote change).
- Plugins get a chance to inspect it, add steps (an input rule turns
##into a heading), or block it. - The engine applies it, checks the schema, and produces the new state and a mapping.
- The view reconciles the DOM for changed blocks and restores the DOM selection.
- Listeners run: the toolbar recomputes state, history records the group, the persistence adapter marks the document dirty, and the host's
onChangefires.
Steps 1 to 4 must finish quickly because they block the next frame. Step 5 contains slower, non-visual work (serialization for onChange, autosave, analytics), so it runs after the paint, batched, or in idle time. That split is what keeps typing fast in big documents, and we come back to it in the performance chapter.
PUT with If-Match so a stale save can't overwrite a newer version.Decision How does the host app get the content out?
What we pick: Uncontrolled, with events and an imperative handle. The editor's state is too large and too sensitive (selection, composition) to round-trip through host state on every key. onChange tells the host something changed. The host pulls JSON when it saves. Small comment boxes can still opt into a debounced serialized value for convenience.
Check your understanding · Exercise 9
Why does the core of the editor know nothing about React or the network? Select all that apply.
Interface and saved format: props, events, JSON, HTML, Markdown
A component used by many teams is judged by its API. It should be small, hard to misuse, and stable for years, because every breaking change means a migration in every product. The saved format matters even more: documents written today must open in five years.
Props, events, and the handle
type RichTextEditorProps = {
initialContent?: DocJSON; // loaded once; use handle.setContent later
schema?: "comment" | "article" | SchemaSpec;
extensions?: Extension[]; // mentions, embeds, custom node views
placeholder?: string;
readOnly?: boolean;
label: string; // accessible name, required
onChange?: (e: { version: number; isEmpty: boolean }) => void;
onSelectionChange?: (s: { marks: string[]; blockType: string }) => void;
onMentionQuery?: (q: string, signal: AbortSignal) => Promise<MentionItem[]>;
onUploadImage?: (file: File, onProgress: (p: number) => void) => Promise<{ url: string }>;
draftKey?: string; // enables local drafts in IndexedDB
};
type RichTextEditorHandle = {
getJSON(): DocJSON;
getHTML(): string;
getMarkdown(): string;
setContent(doc: DocJSON, opts?: { addToHistory?: boolean }): void;
run(command: string, ...args: unknown[]): boolean;
focus(position?: "start" | "end"): void;
};
A few choices worth explaining. label is required, because an editor with no accessible name is announced as just "edit text". Mentions and uploads are callbacks, not URLs: the editor doesn't know the host's API, auth, or user search, so it asks the host, and the mention callback receives an AbortSignal so stale queries are cancelled.
onChange carries a version number and isEmpty, which is enough to enable a Submit button or mark the page dirty without serializing anything.
JSON is the source of truth
Decision What format do we store in the host's database?
What we pick: Versioned JSON. It's the only one of the three that stores exactly what the editor means, including mention ids and embed settings. HTML and Markdown are exports produced from it, never stored as the truth. The server keeps a cached HTML rendering for read-only pages and email if it needs one.
{
"type": "doc",
"version": 3,
"blocks": [
{ "type": "heading", "id": "b1", "level": 2,
"content": [{ "type": "text", "text": "Launch plan", "marks": [] }] },
{ "type": "paragraph", "id": "b2", "content": [
{ "type": "text", "text": "Ask ", "marks": [] },
{ "type": "mention", "userId": "u_42", "label": "Priya" },
{ "type": "text", "text": " about the ", "marks": [] },
{ "type": "text", "text": "budget", "marks": [{ "type": "bold" }] }
] }
]
}
HTML and Markdown export
- HTML export walks the model and emits one fixed tag per node and mark:
p,h2,ulandli,strong,em,awithrel="noopener noreferrer". Text is escaped. URLs are re-checked against the allowlist. Mentions becomeaorspanelements with adata-user-idattribute. Because the model has one shape per meaning, the HTML has one shape too, which makes it safe to cache and diff. - Markdown export maps nodes to Markdown syntax and escapes characters that would otherwise become syntax (a literal
*in text becomes\*). Content Markdown can't express (underline, image width) is either dropped or written as inline HTML, and the export should say which in its docs. - Plain text (for notifications, search indexing, and the
text/plainclipboard flavor on copy) joins block text with newlines and renders mentions as@Name. - Copy puts both
text/htmlandtext/plainon the clipboard, plus the editor's own JSON under a custom type, so pasting between editors on your site is lossless. Browsers limit who can read custom types (Safari exposes them only to pages from the same origin), so the HTML flavor must stay good enough on its own.
Schema versions and migrations
Schemas change: version 2 adds a callout block, version 3 renames code_block to codeBlock. Each saved document carries its version. On load, the editor runs it through an ordered list of pure upgrade functions (2 to 3, then 3 to 4) before validating. Old clients that meet a newer document they don't understand show it read-only rather than silently dropping unknown nodes on save, which would destroy data.
Check your understanding · Exercise 10
Why store versioned JSON instead of the exported HTML? Select all that apply.
Autosave, drafts, and hooks for collaboration
Losing text is the worst editor bug, and most text is lost not by the editor but around it: a closed tab, a crashed browser, an expired session, a flaky train connection. The editor protects against this in two layers: a local draft that is written within a second of every change, and an autosave to the host's server that runs in the background. The form-state lesson covers autosave for ordinary forms; here we focus on what's different for a large document.
Local drafts
When the host passes a draftKey (for example ticket-123-description), the persistence adapter saves the document to IndexedDB about 500 ms after the last change. IndexedDB, not localStorage, because localStorage is synchronous (it blocks the main thread while writing) and limited to roughly 5 MB per origin, which a long article with pasted content can approach.
The draft record holds the JSON, the schema version, the server version it was based on, and a timestamp. When the editor mounts with a draftKey and finds a draft newer than the server copy, it offers to restore it rather than silently replacing the server content.
Drafts must be cleaned up: delete the draft after a successful save and expire old drafts after, say, 30 days. On shared computers, drafts should be scoped by user id and cleared on logout, because they contain whatever people wrote.
Autosave to the server
Autosave sends the document to the host's save function after typing pauses for about 1 to 2 seconds, with a maximum interval (every 10 seconds of continuous typing) so a long burst still gets saved. The save is a small state machine, because several things can happen at once: the person keeps typing while a save is in flight, the network fails, or the server rejects the save because someone else changed the document.
- Only one save in flight. If changes arrive during a save, mark the document dirty again and save once more when the current save returns, with the latest content. Never run two saves in parallel: the older one could finish last and overwrite newer content.
- Optimistic concurrency. Send the server version the content was based on (an
If-Matchheader with an ETag, or abaseVersionfield). If the server has a newer version, it replies409 Conflictor412 Precondition Failed, and the editor must not silently overwrite. For a single-editor document, show "This document was changed elsewhere" with options to reload or keep yours. - Retry with backoff on network errors (1 s, 2 s, 4 s, up to about 30 s), and show a quiet "Offline, saved on this device" status since the draft is safe in IndexedDB.
- Leaving the page. On
visibilitychangetohidden, flush any pending save withfetchandkeepalive: true(its body limit is 64 KB in total across in-flight keepalive requests, so large documents may not fit) and make sure the local draft is written. Usebeforeunloadto warn only when there are unsaved changes that also failed to reach the draft store.
For large documents, sending the full JSON on every save gets heavy. A rough estimate for 50,000 words: about 300 KB of text plus per-block and per-run overhead, so roughly 400 to 800 KB of JSON. Two fixes: compress the request body, or send the transactions since the last acknowledged version instead of the whole document. The second option is the first step toward collaboration, which is why it's worth designing the save path around transactions from day one even if version 1 sends full documents.
Collaboration hooks: what to build now so you don't rewrite later
Live collaboration is out of scope, but the brief says not to block it. The google-docs guide covers the merge algorithms; operational transformation (OT) and CRDTs (conflict-free replicated data types) each have their own lessons. What matters for the editor component is that the core already has the four properties a sync layer needs:
- Changes are data. Transactions with steps can be sent, received, rebased, or converted to CRDT operations. A DOM-diff editor has nothing to send.
- Positions can be mapped. Remote changes shift the local selection, comments, and pending suggestions through the same mapping used for undo.
- Transactions carry an origin. A remote change is applied with
origin: "remote"andaddToHistory: false, so undo skips it and the local person's undo only removes their own work. - The view accepts changes it didn't initiate. Rendering is driven by state, not by the input event, so a paragraph changed by someone else re-renders like any other, while the composition guard protects the block the local person is typing in.
With those in place, adding collaboration is a plugin. With Yjs (a widely used CRDT library), bindings such as y-prosemirror and @lexical/yjs map the editor tree to a shared Yjs XML type, apply local transactions to it, and turn remote Yjs updates into editor transactions. The Yjs UndoManager replaces the local history so undo stays per-person. Cursors from other people are drawn as decorations: visual overlays that are not part of the document.
Check your understanding · Exercise 11
A person types for 30 seconds on a weak connection. Saves take 3 seconds each. Which autosave behaviors are correct? Select all that apply.
Accessibility: an editor everyone can use
Choosing contenteditable over a canvas already did most of the accessibility work: screen readers know how to read and edit an editable region, move by character, word, and line, and announce formatting changes they can see in the DOM. Our job is not to break that, and to make the parts the browser doesn't know about (toolbar, mentions, embeds, custom shortcuts) just as usable.
Semantics of the editing area
- Give the editable element
role="textbox"andaria-multiline="true", so assistive technology knows Enter creates a new line rather than submitting. Name it witharia-labeloraria-labelledbyfrom the requiredlabelprop. - Render real semantic elements from the model:
h2for headings,ul,ol, andlifor lists,blockquote,strong,em,a. Screen reader users jump by heading and list inside the editor just as on a page. A heading rendered as a styleddivis invisible to them. - Images need alt text. The image node view has an alt text button, and pasted images without alt get a visible "Add description" prompt. Store
altin the model; an empty string marks a decorative image on purpose. - Mentions and embeds are
contenteditable="false"islands. Give them an accessible name ("Mention: Priya Shah", "Embedded video: Launch demo") so a screen reader doesn't read an unlabelled chip or skip it. - Placeholder text is shown with CSS from a data attribute on the empty block, and also exposed with
aria-placeholderso it's announced but never becomes part of the content.
Keyboard: shortcuts and getting in and out
Everything must work without a mouse. Formatting has the familiar shortcuts: Ctrl or Cmd plus B, I, U, K for a link, and Ctrl+Alt plus 1 to 3 for headings (use the platform modifier, Cmd on macOS). Document them in a shortcuts dialog and in each button's tooltip and aria-keyshortcuts attribute.
The trickier question is Tab. In a text editor, people may expect Tab to indent a list item, but if Tab never leaves the editor, keyboard users are trapped, which fails WCAG 2.1.2 (No Keyboard Trap). A common compromise: Tab and Shift+Tab indent and outdent only inside list items, and move focus out of the editor everywhere else. Escape always moves focus out of the editor to the next sensible element; say which you chose and how users learn it.
The toolbar follows the WAI-ARIA toolbar pattern: one Tab stop for the whole toolbar, arrow keys move between buttons (roving tabindex), and Home and End jump to the first and last. Toggle buttons use aria-pressed, and the block type menu is a menu button with aria-haspopup.
A shortcut moves focus from the text to the toolbar and back without losing the selection; Alt+F10 is the convention TinyMCE and CKEditor use. When focus returns, restore the model selection, since clicking or focusing toolbar buttons can collapse the DOM selection.
Announcing what isn't visible in the text
Some changes are invisible to a screen reader that is reading the text. Pressing Ctrl+B with a collapsed caret changes nothing in the DOM yet, converting ## into a heading changes the element silently, and a failed upload shows only a red border.
Use one visually hidden live region with aria-live="polite" and post short messages: "Bold on", "Heading 2", "Image upload failed, press Enter to retry". Keep them short and don't announce on every keystroke, or the screen reader becomes noise.
The mention list borrows the combobox pattern's focus model. The editing element keeps role="textbox", gets aria-controls pointing at the list, and aria-activedescendant pointing at the highlighted option, so the screen reader announces each option while focus never leaves the text. ARIA doesn't allow aria-expanded on a textbox, so also announce "5 suggestions" in the live region when the list opens. The aria-patterns and keyboard-navigation lessons cover these patterns.
Zoom, contrast, motion, and direction
- Size text in
remand let the editor reflow at 400 percent zoom with no horizontal scrolling inside paragraphs. - Floating toolbars and popovers must not cover the caret, and must stay reachable at high zoom; position them with the selection rectangle and flip when there's no room.
- Respect
prefers-reduced-motionfor any popover or toolbar animation. - Support right-to-left text: set
dir="auto"on each block so Arabic and Hebrew paragraphs align correctly next to English ones, and let arrow keys follow the browser's visual caret movement.
Check your understanding · Exercise 12
A screen reader user reports: "I can't tell when I've turned bold on, and I can't get out of the editor." Which fixes address this? Select all that apply.
Performance: a budget for every keystroke
Typing is the most frequent interaction on the web, and people notice lag in text input sooner than anywhere else. The usual rule that a response within 100 ms feels instant is too loose for letters appearing under your fingers; editors aim for the next frame. INP (Interaction to Next Paint) measures this: the time from an input to the next frame that shows its result, and it counts every keystroke. An editor that is fine on first load can still fail INP on a long document because each key does too much.
The per-keystroke budget
At 60 Hz a frame is about 16.7 ms. The browser needs part of that for style, layout, and paint, so the editor's own work per keystroke should stay under roughly 4 to 6 ms on a mid-range laptop. Phones are often three to five times slower for the same JavaScript, which turns 5 ms into 15 to 25 ms: still within a frame or two, and well inside the 200 ms INP target. Measure on real mid-range Android devices, where most problems show up.
| Stage | Target (laptop) | How to stay inside it |
|---|---|---|
| Input handling | under 0.5 ms | Map inputType to a command with a lookup table. No DOM reads beyond the selection. |
| Apply transaction | under 1 ms | Immutable updates touch only the changed block and its ancestors. No full-document scans. |
| Plugins | under 1 ms total | Each plugin looks at the transaction's changed range, not the whole document. |
| Reconcile DOM | under 1 ms | Reference checks skip unchanged blocks. Update one text node's data in place. |
| Selection restore | under 0.5 ms | Skip it when the DOM selection already matches. |
| Style, layout, paint | a few ms | Avoid layout reads (offsetHeight, getBoundingClientRect) during the update. Contain each block's layout. |
| Deferred work | outside the frame | Serialization, autosave, word count, analytics, spell-check extensions: batched after paint or in idle time. |
What usually breaks the budget
- Serializing on every keystroke. Calling
JSON.stringifyon a 500 KB document foronChangecosts several milliseconds on a laptop and much more on a phone. Fire a cheap event instead, and serialize when the host asks or when autosave runs. - Full-document plugins. A word counter or a "find broken links" plugin that walks all 3,000 blocks per key. Make plugins incremental: update counts using the changed range, or debounce them.
- Layout thrashing. Positioning a floating toolbar by reading
getBoundingClientRect()right after writing the DOM forces a synchronous layout. Batch the read into the nextrequestAnimationFrame. - Framework re-renders. A host component that stores editor content in React state and re-renders the toolbar, sidebar, and editor wrapper on each key. Keep editor state outside the host's render cycle.
- Huge mark sets and decorations. Spell-check or search highlights drawn as thousands of wrapper elements. Draw them only for visible blocks, or with the CSS Custom Highlight API where supported, which paints ranges without changing the DOM.
Load and bundle size
The editor usually isn't the first thing on the page: a comment box sits under an article, a description field sits in a ticket. So don't make the page pay for it up front. Render the saved content as static HTML first (read-only and fast, from the server's cached export), and load the editor bundle when the person focuses or clicks into it, or during idle time.
Keep the core small, and load heavy pieces (code-block syntax highlighting, tables, the embed providers) with dynamic import() the first time a document uses them. Swapping static HTML for the live editor must not shift layout: render the same styles, so CLS stays at zero.
Measuring in production
Field data beats lab data for editors, because document sizes and devices vary so much. Report INP with attribution (the web-vitals library tells you which interaction and element), and add the editor's own timings: transaction apply time, render time, and document block count, sampled at a low rate. Log unexpected DOM mutations caught by the observer, composition errors, schema validation fixes on load, and failed saves. These reveal browser-specific input bugs long before users describe them.
Check your understanding · Exercise 13
Typing in a 40,000-word document has an INP of 350 ms on mid-range Android phones. Which changes are likely to help most? Select all that apply.
Running it in the interview
A rich text editor question is a test of whether you know where the difficulty is. Weak answers spend 30 minutes on the toolbar and save innerHTML. Strong answers spend the time on the model, the input pipeline, and rendering, and mention the toolbar in one sentence as "derived from selection". Here is one way to use 60 minutes.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: who embeds it, document size, content types, IME and mobile, collaboration later? | Write the numbers: 50,000 words, 16 ms per keystroke, INP under 200 ms. |
| 5 to 12 | Why raw contenteditable plus execCommand fails. Pick contenteditable for input, own model as truth. | The two-trees picture: model on the left, DOM on the right, arrows both ways. |
| 12 to 22 | Document model: blocks, marks, positions, selection, schema. | The model type in a few lines. A flat-position example. |
| 22 to 32 | Input pipeline: beforeinput and inputType, composition, mobile, selection sync. | The keystroke sequence: event, command, transaction, state, render, selection. |
| 32 to 40 | Transactions, undo grouping, reconciliation by block key. | Undo stack with groups. Changed block highlighted in the render diff. |
| 40 to 48 | Paste pipeline and XSS, mentions and embeds as nodes. | Paste steps from clipboard to transaction. The protocol allowlist. |
| 48 to 55 | Saved format, autosave and drafts, collaboration hooks, accessibility. | The architecture boxes and the save state machine. |
| 55 to 60 | Performance budget and follow-ups. | The per-keystroke budget table. |
Expect the interviewer to pull you into one area and push. The input pipeline and paste are the most common, because they separate people who have built an editor from people who have used one. If they push on collaboration, give the four hooks from the autosave chapter and say that the merge algorithm is its own topic, then go as deep as they want. If they push on the component API, talk about uncontrolled state, the required label, callbacks for mentions and uploads, and schema versions.
Follow-up questions to expect
Think first
How would you add tables?
Tables are the hardest common node. Add table, row, and cell node types to the schema, with cells containing blocks. Selection needs a new kind, a cell selection (a rectangle of cells), because a text range across cells makes no sense for "merge cells" or "delete column".
Commands operate on the cell grid. Paste of a spreadsheet range maps to a table. Ship it as a lazily loaded extension, since most documents never use tables, and test keyboard navigation (Tab moves between cells inside a table, which is one more exception to the Tab rule).
Think first
A browser extension like Grammarly injects elements into the editor and changes text. What happens and what do you do?
Extensions mutate the DOM directly. The MutationObserver sees changes the editor didn't make. Text replacements (a grammar fix) should be read back as a transaction, like any observed edit. Injected decoration elements that aren't text should be ignored, not reconciled into the model, and not removed in a loop that fights the extension.
In practice, editors that want to opt out mark their root with attributes the Grammarly extension respects, such as data-gramm="false" and data-enable-grammarly="false", and log unexpected mutations to find new breakages.
Think first
Product wants Markdown shortcuts: typing - starts a list, **bold** becomes bold. How?
Input rules: a plugin looks at each text-insert transaction, checks the text before the caret against a small set of patterns, and if one matches, appends steps that remove the trigger characters and change the block type or add a mark.
Make the conversion its own undo step right after the typing, so Ctrl+Z immediately after ## turns the heading back into literal ## . Disable rules inside code blocks.
Think first
Next year two people edit the same description at once. What changes in your design?
Little in the core, by design: transactions already carry steps, positions map through changes, and remote transactions can be applied with no history entry. Add a collaboration plugin, for example a Yjs binding, that syncs the document through a provider over WebSocket, swap local history for a per-user undo manager, and draw remote cursors as decorations.
The save path changes most: the server now stores the shared document, so per-user autosave becomes sync, and local drafts become an offline cache of the shared state. The google-docs guide covers that side.
Check your understanding · Exercise 14
An interviewer has 10 minutes left and asks "What would you cut from version 1 to ship sooner?" Which answers show good judgment? Select all that apply.
Summary
- The DOM is a view, not the data. Keep a document model (a tree of blocks with marked text runs, checked by a schema) as the source of truth, and use
contenteditableonly as the input surface. - Listen to intent, not keys:
beforeinputwithinputTypecovers typing, autocorrect, dictation, paste, and deletion. During IME composition, let the browser own the DOM and read the result back atcompositionend. - Every change is a transaction of invertible steps with a position mapping. Undo, autosave, plugins, and future collaboration all build on that one shape.
- Group undo by time, kind, and place, and restore the selection with each undo step.
- Render by stable block keys and skip unchanged blocks by reference. Update text nodes in place so IME and phone keyboards keep working.
- Paste goes through
DOMParserand the schema's parse rules, neverinnerHTML. The allowlist schema is the XSS defense, backed by server validation and safe export. - Store versioned JSON. HTML, Markdown, and plain text are exports.
- Budget a few milliseconds of editor work per keystroke and defer serialization, autosave, and full-document plugins until after paint.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Input Events Level 2 (W3C) The spec for beforeinput, inputType values, and getTargetRanges.
- InputEvent.inputType (MDN) The list of input types and browser support.
- Document.execCommand (MDN) Marked deprecated, with notes on why editors moved away from it.
- CompositionEvent (MDN) compositionstart, compositionupdate, and compositionend.
- ProseMirror Guide Documents, schemas, transactions, steps, position mapping, and the view, explained by the library's author.
- Lexical documentation Editor state, nodes, updates, and the reconciler.
- Slate documentation A JSON tree model with paths and operations, rendered with React.
- WAI-ARIA Authoring Practices: Toolbar pattern Roving tabindex and keyboard rules for the formatting toolbar.
- Interaction to Next Paint (web.dev) How INP is measured and the 200 ms threshold.
Concepts behind it
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.