Design a markdown editor with live preview
A text box where people write markdown and see it rendered as they type. The hard parts are smart editing that keeps native undo, rendering fast enough for long documents, never letting user HTML run as script, keeping the source and the preview scrolled together, pasting images, and saving drafts that survive a crash.
The question
Design a reusable markdown editor for our product. People use it to write issue descriptions, comments and long docs pages. They type markdown on one side and want to see what it will look like before they post it.
Walk me through how the editing surface works, how markdown becomes the preview and how fast that is, how you keep it safe when anyone can type HTML, how the preview scrolls with the source, how images get in, and what the component API looks like for the teams that embed it.
Real-life examples: The Write and Preview tabs in GitHub issues and pull requests, The Stack Overflow question and answer editor, The dev.to post editor, HackMD with its side-by-side preview, Obsidian and Bear, which style markdown as you type, Docs tools with a split source and preview pane
Requirements: what to ask before drawing anything
Markdown is a plain text format where a few symbols mean formatting: **bold**, # Heading, - list item, [link](https://example.com). A markdown editor lets you type that text and shows a preview: the same text turned into real HTML, with real headings, lists and links. You've used one in a GitHub issue, a Stack Overflow answer, a dev.to post, HackMD, or a notes app like Obsidian or Bear.
The text people type is the source. The thing we save and send to the server is that source, not the HTML. That one fact shapes the whole design: the preview is a view we can always rebuild, so it can be slow to update, thrown away, or rendered in a worker. The source is the precious part.
Several neighbours have their own guides, so this one links to them instead of repeating them. The rich text editor guide covers editing formatted text directly (WYSIWYG, short for what you see is what you get). The LLM chat UI guide covers rendering markdown that streams in token by token. The CodePen guide compares code editor libraries in depth.
The mention input guide covers finding the caret and handling input method composition in a textarea. The tabs guide covers the tab pattern behind Write and Preview, and the file uploader guide covers the upload itself. Here we focus on what is left: a source-plus-preview markdown editor.
Ask these questions first. Each answer moves the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who writes here? | Developers and non-developers. Many know **bold**, many don't. | We need a toolbar and shortcuts, not only raw syntax. |
| How long are documents? | Comments are 200 to 2,000 characters. Docs pages reach 50,000 characters (about 1,500 lines). We test with 200,000. | Short text can re-render on every pause. Long text needs a worker and stable DOM. |
| Which markdown flavor? | CommonMark plus GitHub Flavored Markdown (GFM): tables, task lists, strikethrough, autolinks. | We pick a parser that follows CommonMark and covers GFM, and run the same one on the server, so the preview matches the post. |
| Can people write raw HTML? | Yes, some (like details and kbd), because CommonMark allows it. | Sanitizing becomes a core part of rendering, not an afterthought. |
| Where is the final HTML made? | On the server, with the same parser and rules. | The client preview is a fast guess; the server render is the truth. |
| Images? | Paste or drag images; they upload to our storage and become a markdown link. | An upload flow with placeholders inside the text. |
| Code, math, diagrams? | Code highlighting yes; math (KaTeX) and Mermaid diagrams on docs pages only. | Heavy renderers load lazily and only when the text uses them. |
| Devices? | Desktop first; phones must work for comments. | Split view on wide screens, Write and Preview tabs on narrow ones. |
| Accessibility target? | WCAG 2.2 AA. | No keyboard trap in the text box; tabs and preview must be announced well. |
Functional requirements
- Type markdown in a text area with helpful behaviors: continue lists on Enter, indent with Tab, wrap a selection in
**with Ctrl or Cmd+B. - See a preview: in a Preview tab on narrow screens, side by side on wide screens.
- The preview supports CommonMark and GFM, highlighted code blocks, and (where turned on) math and diagrams.
- Paste or drop images; they upload and appear in the text as
. - Drafts save on their own and come back after a reload or a crash.
- Teams embed it with a small API: value, change handler, plugins, sanitize rules, an upload handler.
Non-functional requirements
- Typing never waits for the preview. A key press shows up in the text in under 16 ms, one frame, even on a 50,000 character document.
- The preview catches up within about 200 ms after the person pauses.
- Safe by default: no script from user content ever runs, in the preview or in the posted result.
- The editor shell adds under 15 KB gzipped to the page. The parser and sanitizer load on first focus; highlighting, math and diagrams load only when the text uses them.
- Works with keyboard only and with screen readers.
- No lost work: at most a few seconds of typing can be lost if the tab crashes.
Out of scope
- Real-time collaboration with several cursors. That needs OT or CRDTs, covered in the Google Docs guide.
- Full WYSIWYG editing, covered in the rich text editor guide.
- @-mentions and their popup, covered in the mention input guide.
- The server's storage and search of posts.
Check your understanding · Exercise 1
Which answers to the scope questions change the design the most? Select all that apply.
What makes this hard
A markdown editor looks like a textarea next to a div. Wire onChange to a parser, set innerHTML, done. That version takes ten minutes to build and breaks in five ways as soon as real people use it. Each way gets its own chapter or section below.
- Two audiences, one surface. Power users want raw syntax and speed. Beginners don't know that
**means bold. The editing mode (source, split, or styled-as-you-type) decides who is happy. - Typing must stay instant while the preview is expensive. A fast parser handles 50,000 characters in a few milliseconds on a laptop, and several times that on a mid-range phone. Replacing the whole preview DOM costs far more: the browser rebuilds, styles and lays out every block, and every image and embed reloads. Do that on every key press and typing stutters.
- The input is hostile. CommonMark lets people write raw HTML. Anyone can type an
imgwith anonerrorhandler or a link tojavascript:alert(1). The preview runs on our origin, next to the user's session, so a missed case is a stored cross-site scripting (XSS) bug. - Two views of one document. The source is lines of text. The preview is boxes of different heights: a 3 line table can be 400 pixels tall, a 40 line code block can be short when folded. Scrolling one and keeping the other in step needs a map between them.
- Edits come from many places. The person types, the toolbar inserts
**, Enter continues a list, a finished upload swaps a placeholder for a URL, a draft gets restored. Every one of these must keep the caret in the right place and keep Ctrl or Cmd+Z working.
Notice what is not on the list: storing the content. The value is a plain string. It's easy to save, diff, search and send. That is markdown's biggest advantage over a rich text document model, and it's why GitHub, Stack Overflow and most developer tools chose it.
Here is the whole component as the reader sees it. Keep it in mind; every chapter fills in one part.
Think first
If the saved value is just a string, why not render the preview on the server, like the final post, and skip the client parser entirely?
You can, and GitHub's Preview tab works this way. Open the browser's network panel and click Preview: the text is posted to a GitHub preview endpoint, and the server sends back rendered HTML. GitHub offers the same rendering publicly as the REST API's POST /markdown. It guarantees the preview matches the posted result exactly.
It costs a network round trip each time, often a few hundred milliseconds, plus server load. That's fine for a tab you click once. It's too slow for a live side-by-side preview that updates as you type.
So a common answer is both: a client parser for the live preview, and the server render as the source of truth when the post is saved. Using the same library and options on both sides keeps them close.
Source, split, or styled as you type
The first decision is what the writing surface looks like. It sounds like a visual choice, but it picks the editing engine, the bundle size, and who the product serves. Products made different calls here, and each call fits its audience.
The naive answer is the tab design: one textarea, and a Preview tab that hides it. It's what GitHub ships for comments, and it works well for short text.
On a 3,000 word docs page it breaks. You write a table, switch tabs to check it, switch back, and the caret and scroll position have to be restored. People end up flipping tabs dozens of times.
Decision Which editing mode should the component offer?
What we pick: Offer the first two, picked by width. Under about 900 pixels, Write and Preview tabs. Above it, a split view with a toggle to hide the preview. That serves short comments and long docs pages with one engine: a source editor plus a rendered preview. The hybrid is a different product (a notes app) and pulls in a full editor engine; we leave it out, but our pipeline design does not block it later.
Why not WYSIWYG, like Google Docs? Because then the saved value is a document tree, and markdown becomes an import and export format that loses things. Stack Overflow's newer editor offers both a rich mode and a markdown mode on top of ProseMirror, and keeping the two in sync is a project in itself. The rich text editor guide covers that path.
Switching modes without losing your place
The mode is a view setting, not data. Store it as view: "write" | "preview" | "split" in the editor's state and remember the person's last choice in localStorage. Two details matter when switching:
- Keep the
textareamounted when the Preview tab is showing; hide it with thehiddenattribute. Unmounting it throws away the native undo history and the selection. - When going back to Write, put focus back on the text box only if the person pressed the Write tab with a click or key. Never steal focus on a resize from split to tabs.
Check your understanding · Exercise 2
A team wants the editor for short code review comments on phones and laptops. Which statements are true? Select all that apply.
The editing surface, programmatic edits and undo
Now pick the element people type into. A plain textarea gives you the browser's own caret, selection, spellcheck, autocorrect, input method editors (IMEs, used to type Chinese or Japanese), mobile keyboards, and undo, all for free. A code editor library gives you syntax colors, line numbers and a real editing API, at a cost.
Decision What should people type into?
What we pick: Default to the smart textarea: it fits comments and most docs pages, costs almost nothing, and inherits native accessibility and mobile behavior. Put CodeMirror 6 behind the same component API as an opt-in surface: "code" for docs teams who want syntax colors and very long files, and load it with a dynamic import(). Avoid hand-rolled contenteditable.
The smart behaviors people expect
These are small, but they are what makes a textarea feel like an editor. Each reads the current line around the caret and replaces a range.
| Key | What happens | Edge cases |
|---|---|---|
| Enter inside a list item | Insert a newline plus the same marker: - , * , 1. becomes 2. , - [ ] stays unchecked. | On an empty item, remove the marker and end the list instead. Shift+Enter inserts a plain newline. |
| Tab and Shift+Tab | Indent or outdent the selected lines by two spaces (or four inside ordered lists). | Must not trap keyboard users; see below. |
| Ctrl or Cmd+B, I, E | Wrap the selection in **, _, or backticks. Pressed again on a wrapped selection, unwrap it. | With no selection, insert the pair and put the caret between. |
Typing (, [ or a backtick over a selection | Wrap the selection instead of replacing it. | With no selection, insert one character as normal. Auto-pairing on every bracket annoys prose writers, so keep it to selections. |
| Pasting a URL over selected text | Turn it into [text](url). | Only when the clipboard is a single valid URL. |
Here is the Enter handler for lists. It finds the current line, matches a list marker, and either continues or ends the list. The insert helper comes later in this chapter: it is how we keep undo working.
const LIST = /^(\s*)([-*+]|\d+[.)])(\s+\[[ xX]\])?\s+/;
function onEnter(el: HTMLTextAreaElement, e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229 || e.shiftKey || el.selectionStart !== el.selectionEnd) return;
const pos = el.selectionStart;
const lineStart = el.value.lastIndexOf("\n", pos - 1) + 1;
const line = el.value.slice(lineStart, pos);
const m = LIST.exec(line);
if (!m) return;
e.preventDefault();
if (line.length === m[0].length) {
// Empty item: remove the marker and end the list.
el.setSelectionRange(lineStart, pos);
insert(el, "");
return;
}
const [, indent, marker, task] = m;
const next = /\d/.test(marker) ? `${parseInt(marker) + 1}${marker.slice(-1)}` : marker;
insert(el, `\n${indent}${next}${task ? " [ ]" : ""} `);
}
Note the composition check. While someone types with an IME, Enter confirms the composed word; it must not continue a list. Safari has long fired that confirming keydown after compositionend with isComposing false, so the handler also checks keyCode 229. The mention input guide explains composition events in more depth.
Tab without a keyboard trap
In a normal textarea, Tab moves focus to the next control. If we take Tab for indenting, a keyboard user can get stuck. WCAG 2.1.2 (No Keyboard Trap) says focus must be able to leave using only the keyboard, and if that takes more than the standard keys, the person must be told how.
CodeMirror 6 handles this by not binding Tab at all unless you add indentWithTab. When Tab is bound, pressing Escape and then Tab still moves focus out, and Ctrl+M (Shift+Alt+M on macOS) toggles a mode where Tab always moves focus. Its docs ask you to tell people about these escape hatches. We copy the rule:
- Tab indents only when the caret is in a list or a code fence, or when lines are selected. On a plain prose line, Tab moves focus as usual.
- After Escape, the next Tab or Shift+Tab always moves focus. Any other key turns indenting back on.
- Say so in the help text under the editor, and in the
aria-describedbytext of the text box.
Try it. The lab below is a small textarea with these behaviors. Press Enter in the list, Tab and Shift+Tab, select a word and press the bold button, then Escape and Tab. The log shows each edit and whether it landed on the native undo stack.
Check your understanding · Exercise 3
Someone is on the line 3. with nothing after the marker and presses Enter. What should happen? Select all that apply.
Programmatic edits that keep undo
Every smart behavior, toolbar button and upload changes the text from code. The obvious way is el.value = newText. Do that, then press Ctrl or Cmd+Z: in most browsers the undo history is gone or confused, because the browser can't tell what changed. People lose work and stop trusting the editor.
Decision How should code change the text so undo still works?
What we pick: Use execCommand("insertText") for every programmatic edit, with a setRangeText fallback (plus a dispatched input event) if it returns false. It is the only way to join the native undo stack today. GitHub's open source markdown-toolbar-element does the same with a similar fallback, and the comments thread guide's toolbar makes the same choice. If you use the CodeMirror surface, use its transactions instead: they go into CodeMirror's own history.
export function insert(el: HTMLTextAreaElement, text: string) {
el.focus();
const ok = document.execCommand("insertText", false, text);
if (!ok) {
// Fallback: correct text, but this step won't be undoable.
el.setRangeText(text, el.selectionStart, el.selectionEnd, "end");
el.dispatchEvent(new Event("input", { bubbles: true }));
}
}
export function wrap(el: HTMLTextAreaElement, mark: string) {
const { selectionStart: s, selectionEnd: e, value } = el;
const inner = value.slice(s, e);
const wrapped = value.slice(s - mark.length, s) === mark && value.slice(e, e + mark.length) === mark;
if (wrapped) {
el.setSelectionRange(s - mark.length, e + mark.length);
insert(el, inner);
} else {
insert(el, mark + inner + mark);
el.setSelectionRange(s + mark.length, s + mark.length + inner.length);
}
}
The figure follows one Enter key press from the keyboard to the preview. Notice that the text box updates in the same frame, while the preview waits for a pause. That split is the heart of the design.
insertText, one Cmd+Z removes the new marker.A toolbar that teaches the syntax
The toolbar is for people who don't know markdown yet. Each button inserts syntax rather than hiding it, so after a few clicks people learn that ** means bold and start typing it. Build it as the WAI-ARIA toolbar pattern: a div with role="toolbar" and an aria-label, aria-controls pointing at the text box, and arrow keys moving between buttons so the toolbar is one Tab stop.
A button click moves focus away from the text box, but the textarea keeps its selectionStart and selectionEnd while blurred. So a button handler just calls wrap(el, "**"): the helper focuses the text box, and the edit lands where the selection was.
| Shortcut | Action | Inserted text |
|---|---|---|
| Ctrl or Cmd+B | Bold | **text** |
| Ctrl or Cmd+I | Italic | _text_ |
| Ctrl or Cmd+E | Inline code | a backtick on each side |
| Ctrl or Cmd+K | Link | [text](url) with url selected, ready to paste over |
| Ctrl or Cmd+Shift+P | Toggle Write and Preview | none; GitHub uses this same shortcut |
Make shortcuts discoverable: show them in each button's tooltip ("Bold, Ctrl+B", or "Cmd+B" on a Mac), list them with aria-keyshortcuts on the buttons, and add a small "Markdown is supported" link to a cheat sheet. Bind them on the text box's keydown, not the window, so they don't fight other shortcuts on the page.
Check your understanding · Exercise 4
A finished image upload replaces ![Uploading cat.png...]() with the real link. The person then presses Cmd+Z. What should be true? Select all that apply.
From markdown to preview, fast
Markdown has a spec now. CommonMark (2014 onward) pins down the hundreds of edge cases the original 2004 Markdown left vague, like how a list nested in a quote behaves. GitHub Flavored Markdown (GFM) is a strict superset that adds tables, task lists (- [x] done), strikethrough (~~old~~), extended autolinks (a bare www.example.com becomes a link), and a filter for some dangerous raw HTML tags. Users copy text between GitHub and our product, so we want both.
Decision Which parser should turn the source into HTML?
What we pick: Use markdown-it for the live preview: fast, CommonMark compliant, with line maps for scroll sync and a mature plugin set. Use the same version and options on the server for the final render, so preview and post match. If the product already transforms markdown on the server with unified (to lint links or build a table of contents), or must match GitHub's output exactly, use unified on both sides instead: matching the server matters more than a few milliseconds.
When to re-render: every key, after a pause, or by block
Naive: re-parse and set preview.innerHTML on every input event. On a 50,000 character page, parsing takes a few milliseconds on a laptop, but rebuilding, styling and laying out 1,500 lines of DOM takes far longer. On a mid-range phone, a rough estimate is 30 ms or more of main thread work per key press. That's two dropped frames per key, and every image in the preview reloads each time, so the preview flickers.
Decision How should the preview keep up with typing?
What we pick: Debounce at about 150 ms with a 1 s maximum wait, and patch only the changed blocks. Start the worker only for documents over about 20,000 characters; below that, markdown-it parses in about a millisecond on a laptop, and a worker's message copying isn't worth it. The block patching is what makes the preview calm, and it helps at every size.
Two traps in the worker path. First, replies can arrive out of order if a big parse finishes after a small one, so each request carries a version number and the main thread drops any reply older than the newest one it has applied. Second, DOMPurify needs a DOM, and a worker has none. So the worker only parses; sanitizing happens on the main thread, on changed blocks only, which keeps it cheap.
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({ html: true, linkify: true });
type Block = { key: string; line: number; endLine: number; html: string };
self.onmessage = (e: MessageEvent<{ version: number; text: string }>) => {
const { version, text } = e.data;
const t0 = performance.now();
const env = {};
const tokens = md.parse(text, env);
const blocks: Block[] = [];
const seen = new Map<string, number>();
let start = 0;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].level !== 0 || tokens[i].nesting === 1) continue;
// A top-level block ends here: tokens[start..i].
const [line, endLine] = tokens[start].map ?? [0, 0];
const src = text.split("\n").slice(line, endLine).join("\n");
const n = seen.get(src) ?? 0;
seen.set(src, n + 1);
const html = md.renderer.render(tokens.slice(start, i + 1), md.options, env);
blocks.push({ key: `${hash(src)}:${n}`, line, endLine, html });
start = i + 1;
}
self.postMessage({ version, blocks, ms: performance.now() - t0 });
};
Real code would split the lines once instead of per block, and would add a data-line attribute to each block's outer element (next chapters). The key is a hash of the block's source plus a counter for duplicates. When you type in paragraph 40, only paragraph 40's key changes; the other blocks keep their keys, so React (or a hand-written diff) leaves their DOM alone.
Check your understanding · Exercise 5
A 60,000 character docs page with 30 screenshots flickers and stutters while typing. Which changes help? Select all that apply.
Keeping the preview safe
CommonMark passes raw HTML through. So this is valid markdown, and a naive renderer turns it into live HTML on our page: <img src=x onerror="fetch('/api/token').then(...)">. The preview runs on our origin with the person's cookies.
In the preview alone it only attacks the author. But the same text gets posted, and if the posted page renders it the same way, it attacks every reader: a stored XSS. The XSS lesson covers the attack classes; here we decide where the defense lives.
Three kinds of danger hide in user markdown: raw HTML tags and event attributes (script, iframe, onerror), dangerous URLs ([click](javascript:alert(1)), data:text/html,...), and resources that leak information (an image from a tracker's server learns the reader's IP address and when they opened the page).
Decision How do we stop user markdown from running code?
What we pick: Sanitize the rendered HTML with an allowlist on both sides: DOMPurify in the browser for the preview and the same allowlist on the server for the posted HTML. Sanitize the output, never the input, because the input contains code samples that legitimately show script tags. Add a Content Security Policy as a second wall (the CSP lesson covers it). Use a sandboxed iframe only for the risky extras, like diagrams.
Why not the approach the LLM chat UI guide picks, a syntax tree rendered as React elements with raw HTML turned off? Model output has no reason to contain HTML, so there it's the simplest safe choice. Here people write details and kbd on purpose, so we keep HTML and sanitize it. If you choose the unified pipeline, rehype-sanitize does the same allowlist job on the tree (its default schema follows GitHub's rules) and needs no DOM, so it can even run in the worker.
import DOMPurify from "dompurify";
const purify = DOMPurify(window);
purify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
node.setAttribute("rel", "nofollow ugc noopener");
if (/^https?:/i.test(node.getAttribute("href") ?? "")) node.setAttribute("target", "_blank");
}
if (node.tagName === "IMG") {
const src = node.getAttribute("src") ?? "";
node.setAttribute("src", toImageProxy(src)); // our proxy, see below
node.setAttribute("loading", "lazy");
}
});
export function sanitizeBlock(html: string): string {
return purify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["style", "form", "input", "iframe"],
FORBID_ATTR: ["style"],
});
}
A few details from that config. DOMPurify already drops javascript: and other unsafe URL schemes and every on... attribute. It keeps data- attributes by default, which we need for scroll sync. We forbid style so nobody can draw a fake login box over the page.
Links get rel="nofollow ugc noopener". ugc and nofollow tell search engines this is user content, so spammers gain nothing. noopener stops the new tab from controlling ours through window.opener; modern browsers already imply it for target="_blank", and saying it covers older ones.
Images from anywhere, and reader privacy
 is safe from XSS but not from tracking: every reader's browser asks that server for the image, revealing IP address, time, and sometimes the page through the Referer header. And an http: image on our https: page is mixed content, which browsers upgrade or block.
GitHub solves this with Camo, an open source image proxy: the rendered HTML points at camo.githubusercontent.com with a signed copy of the original URL, so readers' browsers only ever talk to GitHub. We do the same: the server rewrites image URLs to our proxy, signed with an HMAC (a keyed hash) so the proxy can't be used as an open relay.
In the client preview, before anything is posted, we either call the same rewrite or add referrerpolicy="no-referrer" to images. Our own uploads skip the proxy.
The lab runs the whole pipeline on a sample document that includes a raw script tag, an onerror image, a javascript: link and a table that you can make bigger. Watch what the sanitizer removes, how parse time grows with size, and which data-line anchors come out for scroll sync. The timing is measured on your device with the lab's tiny parser, so compare sizes, not libraries.
blocked javascript: URL; removed script tag; removed onerror handler
Check your understanding · Exercise 6
Which inputs must the rendered preview neutralize? Select all that apply.
Keeping source and preview scrolled together
In split view, scroll the source to the middle of a long page and the preview should show the same part. The naive version scrolls by percentage: if the source is 40% down, put the preview 40% down.
It falls apart quickly. A 6 line table might be 30 pixels of source but 500 pixels of preview; three screenshots are three short lines of source but 1,500 pixels of preview. By the middle of a real docs page, a percentage sync is off by whole screens.
Decision How should the two panes stay in step?
What we pick: Anchor mapping with interpolation, in both directions, plus a caret-follow when the person types. The parser already gives us source lines for free, and the result is exact where it matters: at headings, tables and images. Proportional sync is only acceptable for the tabs layout, where the panes are never visible together.
data-line, so anchors are exact at block starts. Between two anchors the offset is interpolated, which keeps a tall table or image from throwing the sync off by whole screens.The figure shows the idea. Each anchor is a pair: a source line and the top pixel of its block in the preview. When the source's top visible line is 52, and the anchors around it are line 40 at 1,200 pixels and line 60 at 2,000 pixels, line 52 is 60% of the way between them, so the preview scrolls to 1,680 pixels.
type Anchor = { line: number; top: number };
function measureAnchors(preview: HTMLElement): Anchor[] {
const base = preview.getBoundingClientRect().top - preview.scrollTop;
return [...preview.querySelectorAll<HTMLElement>(":scope > [data-line]")].map((el) => ({
line: Number(el.dataset.line),
top: el.getBoundingClientRect().top - base,
}));
}
function lineToOffset(anchors: Anchor[], line: number): number {
let i = anchors.findIndex((a) => a.line > line);
if (i === -1) return anchors.at(-1)?.top ?? 0;
if (i === 0) return 0;
const a = anchors[i - 1], b = anchors[i];
return a.top + ((line - a.line) / (b.line - a.line)) * (b.top - a.top);
}
Which source line is at the top?
In CodeMirror this is one call: view.lineBlockAtHeight(scrollTop). In a textarea it's harder, because long lines wrap, so line 52 is not at 52 * lineHeight.
The trick is a hidden mirror: a div with the same width, font, padding and white-space: pre-wrap, holding one child per source line. Each child's offsetTop is that line's pixel position. The mention input guide uses the same mirror to find the caret. Rebuild it after the debounced render and on resize (a ResizeObserver), never per key.
Avoiding the feedback loop
Setting preview.scrollTop fires a scroll event on the preview, whose handler sets source.scrollTop, which fires a scroll event on the source. The panes jitter or drift as rounding errors bounce back and forth.
Fix it by tracking which pane the person is driving: set driver = "source" on wheel, pointerdown, keydown or focus in the source, and the same for the preview. Only the driver's scroll events cause syncing. Batch the work in requestAnimationFrame so a fast scroll does one update per frame.
Anchors also move when content loads: an image that was 0 pixels tall becomes 400. Watch the preview with a ResizeObserver and re-measure anchors when its height changes. Give uploaded images width and height attributes so the space is reserved before the bytes arrive.
Check your understanding · Exercise 7
After a scroll sync goes live, the panes slowly creep downward on their own. What's the likely cause and fix? Select all that apply.
Code, math, diagrams and pasted images
The base preview is plain HTML. Docs writers want more: colored code blocks, math like $E = mc^2$, and diagrams written as text in a code fence tagged mermaid. Shiki, KaTeX and Mermaid each weigh several times the editor shell, and Prism is small but its language files add up. Shipping them all up front would multiply the editor's size for features most comments never use.
| Extra | Library | How we load it | Safety note |
|---|---|---|---|
| Code highlighting | Shiki (VS Code's TextMate grammars) or Prism (smaller, regex based) | Dynamic import() the first time a fenced block appears; load each language's grammar on demand. | The highlighter gets plain code text and escapes it, so its output is trusted. Run it after sanitizing, on the code block only. |
| Math | KaTeX | Load when the text contains $ pairs or a code fence tagged math. renderToString needs no DOM, so it can run in the worker. | Keep trust: false (the default) and limits like maxExpand so a crafted formula can't hang the tab. |
| Diagrams | Mermaid | Load only when a fence tagged mermaid exists and is near the viewport (an IntersectionObserver). | The default securityLevel: "strict" encodes HTML in labels and turns off click handlers; "sandbox" (still marked beta in Mermaid's docs) also renders each diagram in a sandboxed iframe. Never use "loose" for user text. |
Two rules make extras play well with the block pipeline. First, a code block's key is the hash of its source, so a highlighted block is only re-highlighted when its code changes. Second, extras render asynchronously into their block: show the plain pre first, then swap in the colored version when the grammar arrives. The preview never waits on the slowest extra.
Shiki's default output sets colors with style attributes, which our sanitizer strips. Either insert highlighted HTML after sanitizing (safe, because the highlighter escaped the code itself) or configure it to emit CSS classes. Never feed highlighter output back through a sanitizer that removes what it added, and never run user HTML through the highlighter path.
Pasting and dropping images
People paste screenshots straight from the clipboard. The naive version uploads, waits, and then inserts the link at the caret. But the upload takes 2 seconds, the person kept typing, and the link lands in the middle of a word. GitHub's comment box shows the fix: insert a placeholder right away, like ![Uploading screenshot.png...](), then replace that exact text when the upload finishes, wherever it has moved to.
- Listen to
paste(event.clipboardData.files) anddrop(event.dataTransfer.files) on the text box. Also offer an "Attach files" button with a hiddeninput type="file"for keyboard and mobile users. - Check type (
image/png,image/jpeg,image/gif,image/webp) and size (say 10 MB) before uploading; reject others with a clear message. - Make each placeholder unique: add a counter when two files share a name, since replacement finds it by text.
- Upload with the host's upload handler. The file uploader guide covers presigned URLs, progress and retries; the editor only needs a promise that resolves to a URL.
- On success, replace the placeholder with
(alt text from the name; prompt the person to improve it). On failure, replace it with nothing and show an inline error with a Retry button. - If the person deleted the placeholder while it uploaded, do nothing on completion: they changed their mind.
- If the text box has focus when the swap happens, save the person's selection, select the placeholder, call
insert, then restore the selection, shifted by the change in length if it sat after the placeholder. If focus is elsewhere (say, the title field), don't steal it: use thesetRangeTextfallback and accept that this one step isn't undoable. - Disable the Submit button (or warn) while uploads are pending, or posts go out with dead placeholders.
Check your understanding · Exercise 8
A person pastes two screenshots both named image.png, then keeps typing. Which designs avoid bugs? Select all that apply.
Architecture, data model, drafts and component API
Now put the pieces together. The rule that draws the boundaries: the text box path must never wait on anything. Everything slow (parsing, sanitizing, highlighting, uploading, saving) hangs off the side of it and reports back later.
insertText; everything slow (parsing, uploads, saving) hangs off to the right and reports back later.| Part | Responsibility | Why it is separate |
|---|---|---|
| Editor shell | Layout, mode (tabs or split), toolbar, help text, status line. | The only part teams restyle; it owns no document logic. |
| Source surface | The textarea (or lazy CodeMirror), key handlers, paste and drop. | Swappable behind one small interface: get and set selection, insert text. |
| Edit commands | insert, wrap, list continuation, indent. Pure functions of text plus selection, applied through insert. | Easy to unit test; reused by toolbar, shortcuts and uploads. |
| Render scheduler | Debounce with max wait, versioning, choose main thread or worker. | Keeps timing rules in one place. |
| Parse worker | markdown-it plus plugins, split into keyed blocks with source lines. | Heavy CPU work off the main thread for long documents. |
| Sanitizer and preview | DOMPurify per changed block, patch the DOM, run extras, measure anchors. | The security boundary is one function everyone can audit. |
| Scroll sync | Anchors, mirror measurements, driver flag. | Only exists in split view; can be dropped for comments. |
| Upload manager | Placeholders, calls the host's upload handler, swaps text. | Talks to the network, so it must be cancellable and isolated. |
| Draft store | Saves and restores drafts in IndexedDB. | Survives crashes, works offline. |
Data flows one way. The text box changes the text; the text goes to the scheduler and the draft store; the preview follows later. The only thing that writes back into the text, apart from the person, is an edit command, and every edit command goes through insert, so undo and onChange stay correct.
The end-to-end picture adds the server. Uploads go to storage and come back as CDN URLs. On submit the host posts the markdown string, and the server renders and sanitizes it with the same rules, rewriting outside images to the proxy. Readers get that stored HTML, never the client preview.
The data model
// Component state (and the host's form state through onChange)
type EditorState = {
value: string; // the markdown source: the only real data
view: "write" | "preview" | "split";
pendingUploads: Map<string, Upload>; // placeholder text -> upload
};
// Derived, rebuilt any time, never saved
type RenderResult = { version: number; blocks: Block[]; ms: number };
type Block = { key: string; line: number; endLine: number; html: string };
type Anchor = { line: number; top: number };
type Upload = { file: File; status: "uploading" | "done" | "failed"; url?: string; abort: AbortController };
// IndexedDB, store "drafts", key = draftKey
type Draft = {
draftKey: string; // e.g. "issue:812:comment:new" or "doc:42"
value: string;
baseVersion?: string; // server version the draft started from
savedAt: number; // Date.now()
};
// localStorage: view mode preference only
type Prefs = { view: "split" | "tabs" };
Autosaving drafts
Losing a long comment to a closed tab or a crash is the most hated failure in any editor. The component saves the text under a draft key that the host gives it, like issue:812:comment:new. Save 1 second after the last change and on visibilitychange to hidden (the most reliable "leaving" event, especially on phones). Delete the draft when the post succeeds.
localStorage is synchronous and small (around 5 MB per origin), and writing 50 KB on every pause blocks the main thread. IndexedDB is asynchronous, holds far more, and can store many drafts with their timestamps. The IndexedDB lesson covers the API; a tiny wrapper like idb-keyval is enough here. The multi-step form guide uses the same autosave pattern.
Restoring needs one judgment call. When the editor mounts and finds a draft, compare it with the server's value. If the draft's baseVersion matches the server's current version, restore it quietly and show "Restored your unsaved draft" with a Discard button. If the server changed since (someone else edited the doc), don't overwrite either side: show both, or offer a diff, and let the person choose.
Drafts older than 30 days are deleted on startup. The browser may delete them sooner: Safari removes script-written storage, IndexedDB included, for sites without a visit in 7 days of Safari use, as the multi-step form guide notes. A draft is a safety net for crashes and closed tabs, not long-term storage.
Check your understanding · Exercise 9
Which draft behaviors are correct? Select all that apply.
The component API
Many teams embed this editor: issue comments, docs pages, release notes. The API decides whether they can use it without forking it. The component API design lesson covers the general rules; here are the choices specific to a markdown editor.
The biggest one: who decides how markdown renders? If each team passes its own parser plugins and sanitize options, the preview drifts from what the server stores, and one team turning on raw iframe tags opens a hole for everyone.
Decision How do hosts customize rendering?
What we pick: Named presets for anything that changes the HTML, and props for everything else (layout, toolbar, upload handler, labels). Teams that need something new add it to the shared preset with a review, which is exactly the friction you want around the sanitizer.
type MarkdownEditorProps = {
value?: string; // controlled
defaultValue?: string; // or uncontrolled
onChange?: (value: string) => void; // every edit, typed or programmatic
preset: "comment" | "docs"; // parser + sanitize rules, shared with server
layout?: "auto" | "tabs" | "split"; // auto: split above 900 px
surface?: "textarea" | "code"; // "code" lazy-loads CodeMirror 6
draftKey?: string; // enables autosave
serverVersion?: string; // for draft conflict checks
onUpload?: (file: File, opts: { signal: AbortSignal; onProgress: (pct: number) => void })
=> Promise<{ url: string; width?: number; height?: number }>;
onSubmit?: () => void; // Ctrl or Cmd+Enter
toolbar?: ToolbarItem[] | false;
label: string; // accessible name, required
placeholder?: string;
maxLength?: number;
};
type MarkdownEditorHandle = {
focus(): void;
insert(text: string): void; // goes through execCommand, so undoable
getSelection(): { start: number; end: number };
};
onChangegets a string on every edit. Hosts that keep the value in a big form store should not re-render the whole page per key; the editor works uncontrolled too, withdefaultValue, and the host reads the value on submit.onUploadreturns a promise of a URL and accepts anAbortSignal. The editor never knows about buckets or presigned URLs, so the same editor works with any storage.labelis required, not optional: an editor without an accessible name fails audits on every page that uses it.- The imperative handle lets the host add text (a saved reply, a quote of another comment) through the same undoable path.
Check your understanding · Exercise 10
A team asks for a prop to allow iframe tags so they can embed videos in release notes. What is a good response? Select all that apply.
Accessibility and performance
A textarea starts out accessible, so the job is mostly not to break it. A preview that changes all the time is the new risk: done badly, a screen reader talks over the person on every pause.
Accessibility
- The text box has a visible
label(oraria-labelfrom the requiredlabelprop) andaria-describedbypointing at short help: "Markdown supported. Press Escape then Tab to leave the editor." - Write and Preview are real tabs:
role="tablist",role="tab"witharia-selected, arrow keys between them, and each panel arole="tabpanel". The tabs guide covers the pattern in full; the keyboard navigation lesson covers roving focus. - In split view the preview is a
sectionwitharia-label="Preview", so it shows up as a landmark region people can jump to. - Do not make the preview an
aria-liveregion. Every debounced render would read out changed content while the person is typing. Use one small polite status region for events that matter: "Image uploaded", "Upload failed", "Draft restored". - Toolbar buttons have names ("Bold"),
aria-keyshortcuts, and the toolbar is one Tab stop with arrow keys inside. - In the
commentpreset, shift heading levels down so a# Titlein a comment becomes anh3, keeping the page's heading outline sane. - If you smooth scroll sync, turn smoothing off under
prefers-reduced-motion: reduce.
Performance
The metric that matters is Interaction to Next Paint (INP): the time from a key press to the next frame. Google calls 200 ms or less at the 75th percentile good, but an editor should aim far lower: the text box itself should paint within one frame, about 16 ms. The runtime performance lesson covers long tasks and how to find them. Our budget per key press is the key handler (under 1 ms), the onChange call into the host, and nothing else.
| Cost | Where it runs | How we keep it small |
|---|---|---|
| Key handler and edit commands | Main thread, every key | Look only at the current line; no parsing. |
Host re-render from onChange | Main thread, every key | Uncontrolled mode, or memoize the rest of the form. |
| Parse | Worker over 20,000 characters, else main thread | Debounced 150 ms, max wait 1 s; stale versions dropped. |
| Sanitize and DOM patch | Main thread, after each render | Only changed blocks; usually one block. |
| Highlight, math, diagrams | Lazy chunks | Loaded on first use; cached by block key. |
| Anchor measuring | Main thread | After render and on resize, inside requestAnimationFrame, not on scroll. |
Two more wins for very long previews. CSS content-visibility: auto on each block lets the browser skip layout and paint for blocks far off screen; set contain-intrinsic-size so the scrollbar stays stable. It makes scroll anchors for skipped blocks estimates, so re-measure when a block comes near the viewport. And measure in production: report render time and document size with each preview update (sampled), so you see the p95 on real devices, not your laptop.
Check your understanding · Exercise 11
A screen reader user says the editor "keeps talking" while they type. What should change? Select all that apply.
Running it in the interview
In 45 minutes you can't cover every chapter in depth. Pick the two or three that the interviewer leans into, and mention the rest in one sentence each. A plan that works:
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask scope questions: document size, raw HTML, server render, images, phones. | "The saved value is the markdown string. The preview is derived, so it can lag and be rebuilt." |
| 5 to 10 | Pick the mode and surface. Draw the wireframe: toolbar, text box, preview. | "Tabs on narrow screens, split above 900 px. A smart textarea, CodeMirror as an opt-in." |
| 10 to 20 | Draw the architecture and the render pipeline. | "Typing never waits: debounce 150 ms, worker for big docs, patch only changed blocks." |
| 20 to 28 | Security. | "CommonMark allows HTML. Sanitize the output with an allowlist, same rules on the server, CSP as a backstop, proxy images." |
| 28 to 38 | One or two deep dives the interviewer picks: scroll sync, undo, uploads, drafts. | Name the failure first (percent sync drifts, el.value kills undo), then the fix. |
| 38 to 45 | Component API, accessibility, what you'd measure. | "Presets shared with the server; no live preview announcements; INP on real devices." |
Draw the wireframe first, then the pipeline. Interviewers relax when they see you split the fast path (the text box) from the slow path (everything else) in the first ten minutes; it shows you know where the performance and safety problems will come from.
Think first
Follow-up: "Now two people need to edit the same docs page at once. What changes?"
The value can no longer be one string that each client overwrites. We need a shared document with operational transformation or a CRDT, such as Yjs with its CodeMirror binding, so concurrent edits merge.
That pushes us to the CodeMirror surface, because a plain textarea can't show remote cursors or apply remote edits without moving the local caret. Undo must become per user, so the CRDT's undo manager replaces native undo.
The preview pipeline doesn't change: it still renders whatever the current text is. Drafts change meaning: the server document is always current, so local storage becomes an offline queue instead of a draft.
Think first
Follow-up: "How would you test the sanitizer?"
Keep a corpus of known XSS payloads (event handlers, javascript: with odd casing and entities, svg and math tricks, broken nesting) and assert the output has no script, handlers or unsafe URLs. Run it against both the client preset and the server preset.
Add a parity test: render a set of real documents on client and server and compare the HTML. Any difference is either a preview bug or a security gap.
Pin the sanitizer version and update it promptly; DOMPurify releases fixes when browsers change how they parse HTML.
Check your understanding · Exercise 12
Which statements would make a strong first ten minutes? Select all that apply.
Summary
- The markdown string is the only real data. The preview is derived, so it can lag, run in a worker, or be rebuilt on the server.
- Pick the mode by audience and width: Write and Preview tabs on narrow screens, split view above about 900 pixels. Live-preview hybrids are a different product.
- A smart
textareacovers most needs. Route every programmatic edit throughexecCommand("insertText")so native undo andonChangekeep working. - Debounce the preview (about 150 ms, 1 s max wait), parse big documents in a worker, and patch only blocks whose source hash changed, so images don't reload.
- CommonMark allows raw HTML. Sanitize the output with an allowlist on client and server, block
javascript:URLs, addrel="nofollow ugc", proxy outside images, and keep a CSP behind it. - Sync scrolling with
data-lineanchors and interpolation, only from the pane the person drives, and re-measure when images load. - Insert unique placeholders for uploads and replace them by text; save drafts to IndexedDB by a host-given key, and check the server version before restoring.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- CommonMark Spec The precise rules for markdown, including raw HTML blocks.
- GitHub Flavored Markdown Spec Tables, task lists, strikethrough, autolinks and the disallowed raw HTML filter.
- markdown-it The parser used here; see the
html,linkifyandvalidateLinkoptions. - remark (unified) The mdast-based alternative, with
remark-gfmandrehype-sanitize. - DOMPurify The allowlist HTML sanitizer, its config options and hooks.
- CodeMirror: Tab handling Why Tab is unbound by default and the Escape then Tab escape hatch.
- MDN: Document.execCommand() The
insertTextcommand and its deprecation status. - WAI-ARIA APG: Toolbar pattern Keyboard model for the formatting toolbar.
- web.dev: Interaction to Next Paint The responsiveness metric the typing budget is measured against.
- Camo, GitHub's image proxy The original open source proxy behind GitHub's camo.githubusercontent.com image URLs.
- GitHub Docs: About anonymized URLs How GitHub uses Camo to hide readers' details from image hosts.
- GitHub REST API: Markdown Server-side rendering of GFM, the same idea as a server-rendered Preview tab.
- Understanding WCAG 2.2: No Keyboard Trap Success criterion 2.1.2, which the Tab handling must pass.
- Mermaid: securityLevel The strict, sandbox, antiscript and loose levels for diagrams from user text.
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.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- 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.
- 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.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- 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.