Design an embeddable code editor
The editor box that playgrounds, online judges, and code review tools embed. Showing colored text is the easy part; the hard parts are catching typed text from every keyboard and input method without letting the browser corrupt the document, storing a 10 MB file so an edit costs microseconds, drawing only the 40 lines on screen, re-highlighting after each keystroke without blocking it, and staying usable with a screen reader.
The question
Our company ships three products that all need a code box: a playground, a coding interview tool, and a pull request review page. Each team has glued in a different library, and each has its own bugs with Japanese input, huge files, and screen readers.
Design one embeddable code editor component that all three can use. How does it take in text, store the document, track cursors, draw only what is visible, highlight syntax as you type, grow through extensions, and stay fast and accessible on a 50,000 line file?
Real-life examples: CodeMirror 6 (Replit, Chrome DevTools sources panel, Jupyter), Monaco, the editor inside VS Code, Ace (built for the Cloud9 IDE), GitHub's web file editor, LeetCode and CodePen editor panes
Requirements: what the host apps need from an editor
A code editor component is the box where you type code: colored text, line numbers, a blinking cursor, and keys like Tab and Ctrl+Z that behave the way programmers expect. It looks like a textarea with colors. It is not. A plain textarea cannot color parts of its text, cannot show a squiggly line under one word, and slows to a crawl when you paste in a 5 MB file.
This is a component question. The host app decides what the code is for: the codepen guide runs it in a sandboxed preview, the online-judge guide sends it to a test runner, and the code-review-diff guide shows two versions side by side. Those guides treat the editor as a part with a clear interface and pick a library.
This guide opens that part up. We design the internals the way the authors of the three libraries behind almost every web editor did: CodeMirror 6 (a modular 2022 rewrite by Marijn Haverbeke), Monaco (the editor of VS Code, packaged for the browser), and Ace (the older editor behind Cloud9). Each made a different bet on the hard parts, so we use them as worked examples and explain why they are built the way they are.
Questions that change the design
- Who edits, and on what? Desktop keyboards only, or phones too? Phone keyboards insert text through autocorrect and prediction, which breaks editors that listen only to key events. Assume desktop first, but phones must work for small edits.
- Which languages and scripts? Code is mostly ASCII, but comments and strings hold Chinese, Japanese, Arabic, and emoji. Typing Chinese needs an IME (input method editor: the small popup that turns keystrokes into characters). Assume full IME support; it is the bug users report most.
- How large can a file get? A LeetCode answer is 50 lines. A pull request may touch a 20,000 line generated file. A minified bundle is 2 MB on a single line. Assume smooth editing up to 100,000 lines and 10 MB, with features turned off above that.
- How smart must it be? Coloring only, or autocomplete, errors as you type, and go to definition? Smart features need a language engine, which may run in a worker or on a server. Assume coloring and bracket matching built in, smart features as optional extensions.
- One editor, or many on a page? A review page can show 30 small read-only editors. That makes startup cost and memory per instance matter more than peak features.
- Collaboration? Live multi-user editing is a layer on top. We only need a document model that can accept remote changes and map cursors through them.
Functional requirements
- Edit text with the keyboard, mouse, touch, paste, drag and drop, and any IME, with undo and redo.
- Multiple cursors and selections, rectangular selection, and the usual code keys: auto indent, bracket closing, comment toggle, move line up and down.
- Syntax highlighting that stays correct while you type, for 10 to 50 languages loaded on demand.
- A gutter with line numbers, fold markers, and lint markers; inline decorations such as squiggly underlines and widgets.
- Search and replace, including regular expressions, over the whole document.
- An extension system so each host app can add keymaps, languages, themes, and panels without forking the core.
- An embedding API: read and set the value, listen to changes, switch language, toggle read only.
Non-functional requirements, with numbers
| Requirement | Target | Why this number |
|---|---|---|
| Keystroke to paint | Under 16 ms on a mid-range laptop, under 50 ms on a mid-range phone | One frame at 60 Hz is 16.7 ms. Many users notice typing lag of about 50 ms or more. |
| Edit cost on a large file | Inserting a character in a 10 MB file costs well under 1 ms | A copy of a 10 MB string on every keystroke would cost several ms and create garbage for the collector. |
| Open time | A 100,000 line file shows its first screen in under 300 ms | The user clicked a file and expects to see it, not a spinner. |
| DOM size | Only the visible lines plus a margin: a few hundred line elements at most | 100,000 line elements would take seconds to lay out and hundreds of MB of memory. |
| Bundle | About 200 KB gzipped or less for the core plus one language, loaded after first paint | The editor must not delay the host page; chapter 9 checks real library sizes. |
| Accessibility | Usable with NVDA, JAWS, and VoiceOver; never traps keyboard focus | WCAG (the Web Content Accessibility Guidelines) 2.1.1 Keyboard and 2.1.2 No Keyboard Trap apply to every editor. |
Out of scope, and why
- Running the code and the preview iframe. The codepen and online-judge guides cover sandboxing and test runs.
- Rich text and prose. Bold, headings, and embedded images belong to the rich-text-editor guide, which uses a tree document model; the markdown-editor guide covers writing prose with a live preview. Code is plain text plus decorations.
- Collaboration algorithms, OT and CRDTs (the two families of merge algorithms for live editing). The google-docs guide and the crdts and collaboration-basics lessons cover them; we only keep the hooks they need.
- Terminal rendering. A terminal is a grid of cells fed by a byte stream; the web-terminal guide covers it.
Check your understanding · Exercise 1
Which answers from the host teams would change the editor's internal design, not just its configuration? Select all that apply.
What makes a code editor hard
Five properties of the problem drive every later decision. Name them early in an interview; each one maps to a chapter.
- The browser wants to own text input, and we can't let it own the document. Only the browser knows how an IME, a phone keyboard, autocorrect, or dictation produce text. But if we let the browser edit our DOM freely, it inserts stray
spanandbrelements and our model drifts away from what is on screen. We need a narrow channel: let the browser produce text, then take it back. - Documents are big and edits are tiny. A keystroke changes one character in a document that may hold 10 million. Any design that copies, re-splits, or re-renders the whole document per keystroke fails at size. Every structure must make an edit cost depend on the size of the edit, not of the file.
- Positions move. A lint error at offset 5,000, a collaborator's cursor, a fold, a search match: all point into the text. When you type at offset 100, every one of them after 100 shifts. The editor needs one shared way to map any position through any change.
- Highlighting needs the whole file but must not cost the whole file. Whether line 9,000 is inside a string depends on every quote before it. Yet a keystroke must repaint in one frame. That forces incremental parsing with a time budget.
- Text must stay real for assistive tech. A canvas or a pile of absolutely placed spans looks fine but reads as nothing to a screen reader. Whatever trick we use to draw only 40 lines, the text under the cursor must be readable and announced.
A sixth, softer property: the editor is a platform. Each host wants its own keymap, languages, and panels, so the core must stay small and everything else must plug in.
The figure is the spine of this guide: input surface (chapter 3), immutable document and transactions (chapter 4), selection (chapter 5), the windowed view (chapter 6), highlighting that catches up after the paint (chapter 7), and extensions at every step (chapter 8).
The input surface: letting the browser type without letting it edit
The naive plan: put tabindex on a div, listen to keydown, and insert event.key. It works for an English keyboard. Then three bug reports arrive.
A Japanese user types "nihon" and gets the letters n, i, h, o, n instead of 日本, because the IME only works in editable elements. An Android user sees nothing at all, because phone keyboards often report every key as Unidentified with keyCode 229. A user pastes from Slack and nothing happens, because there is no paste target. The lesson: text input is an operating system service, and only real editable elements get it.
So the editor needs a real editable element somewhere. The design question is which one, and how much of the browser's editing we let through.
Decision Which element receives the user's text?
What we pick: Our editor uses contenteditable for the content, like CodeMirror 6, because the three host apps include phones and screen reader users, and both get the most from real text in the DOM. We accept the cost of a DOM observer that reads changes back and redraws. We design the input layer behind an interface so an EditContext path can replace it on Chromium later.
The events, in the order they arrive
Modern browsers fire beforeinput before they change the DOM, with an inputType that names the intent: insertText, deleteContentBackward, insertFromPaste, insertParagraph, historyUndo, and about 40 more. Most are cancelable, so the editor can call preventDefault() and apply the change to its own model instead. The big exception is text produced during IME composition: insertCompositionText cannot be canceled. The browser must draw it, and the editor reads it back afterwards.
Composition is the IME's in-progress state. It starts with compositionstart, sends compositionupdate each time the candidate text changes ("に", then "にほ", then "日本" after the user presses Space), and finishes with compositionend carrying the committed text.
Each update also fires beforeinput and input with the inputType insertCompositionText. Meanwhile keydown events still fire, with isComposing true and keyCode 229. Desktop Safari can even deliver the Enter that confirms a candidate just after compositionend, which is why guards check for 229 as well.
keydown handler that runs Enter as a command without checking isComposing.content.addEventListener("keydown", (event) => {
// Keys pressed while an IME is open belong to the IME.
if (event.isComposing || event.keyCode === 229) return;
const command = keymap.lookup(event); // Mod-z, Tab, Enter...
if (command && command(view)) event.preventDefault();
});
content.addEventListener("beforeinput", (event) => {
if (event.inputType === "insertCompositionText") return; // browser draws it
const change = changeFromInputEvent(event, view.state);
if (!change) return; // unknown intent: let the DOM observer read it back
event.preventDefault();
view.dispatch({ changes: change, userEvent: "input.type" });
});
content.addEventListener("compositionend", () => {
observer.flush(); // read the committed text from the DOM as one change
});
Phones, autocorrect, and the clipboard
- Phone keyboards often send
keyCode229 withkeyset toUnidentifiedfor every key, compose even English words, and replace whole words on autocorrect. Chrome on Android may also ignorepreventDefault()onbeforeinputand report Enter or Backspace only as DOM changes. So the DOM observer, which diffs the changed DOM range against the model, is the path that must always work;beforeinputis a fast path on top of it. - Autocorrect and spellcheck turn
functionintoFunctionand paint red lines under every variable. Setspellcheck="false",autocorrect="off", andautocapitalize="off"on the content element. CodeMirror 6 sets these by default. - Copy handles the
copyevent and writestext/plaintoclipboardData, joining multiple selections with line breaks. With an empty selection, VS Code copies the whole line, a habit users expect. - Paste reads
text/plainonly (never HTML, which brings fonts and colors), normalizes\r\nto\n, and, when the pasted text has exactly as many lines as there are cursors, puts one line at each cursor.
Check your understanding · Exercise 2
A Japanese user presses Enter to confirm the IME candidate 日本, and the editor inserts 日本 followed by an extra new line. Which fixes are correct? Select all that apply.
Document and state: immutable values, one path for every change
The naive model is one JavaScript string. Insert a character with doc.slice(0, pos) + ch + doc.slice(pos). For a 50 line answer this is perfect.
For a 10 MB file it builds a new 10 MB string per keystroke (engines can delay the copy, but the first read forces it), and finding line 80,000 means scanning for 79,999 line breaks. Both costs grow with the file, and the editor asks "where does line N start?" many times per frame for rendering, gutters, and cursor movement.
The next idea is an array of lines (string[]): line lookup is instant, and typing inside a line replaces one short string. VS Code used exactly this until 2018. Its team then wrote up why it failed: a user's 35 MB file had 13.7 million lines, each line object cost about 40 to 60 bytes, and the array alone used about 600 MB, roughly 20 times the file. Splitting a big file into millions of strings also made opening slow, and inserting a line in the middle shifts every later entry.
Decision How do we store the document text?
What we pick: We pick the immutable tree of line chunks. Our editor's state is an immutable value (the second half of this chapter), and a document that shares structure between versions makes every snapshot almost free. The piece tree is the better choice if you build a desktop editor around a mutable model and expect files of hundreds of MB; for an embedded web editor tuned for files up to 10 MB, cheap snapshots matter more.
Positions and changes
A position is one number: the offset from the start of the document, counted in UTF-16 code units (the unit JavaScript strings use), with each line break counting as 1. CodeMirror 6 works this way and converts to line and column only for display. Monaco's API uses 1-based line and column pairs instead, with getOffsetAt and getPositionAt to convert. One number is easier to compare, sort, and store in a decoration.
An edit is not a new string. It is a change set: a value that says "keep 4 characters, insert alpha, keep the rest". Because the change is data, any part of the system can answer the question from chapter 2: where does my position go? This is position mapping, and it moves a lint marker at offset 15 to 20 when 5 characters are inserted before it.
A position exactly at the insertion point is ambiguous, so mapping takes a side: assoc = -1 stays before the new text, assoc = 1 moves after it. A marker at the start of a word should use 1, so it stays with the word when someone types in front of it. A marker at the end of a word should use -1, so text typed right after the word stays outside it.
import { ChangeSet, Text } from "@codemirror/state";
const doc = Text.of(["let a = 1;", "let b = 2;"]); // length 21
const change = ChangeSet.of({ from: 4, insert: "alpha" }, doc.length);
change.apply(doc).line(1).text; // "let alphaa = 1;"
change.mapPos(15); // 20: the "b" on line 2 shifted by 5
change.mapPos(4, -1); // 4: stays before the inserted text
change.mapPos(4, 1); // 9: moves after the inserted text
doc.lineAt(15).number; // 2, found in O(log n)
Document: let a = 1;⏎print(a);. 1 pieces. Last change: no edits yet. Marker at offset 11, line 2, column 0.
Check your understanding · Exercise 3
Which statements about the document model are true? Select all that apply.
State, transactions, and the view: one path for every change
The document is one part of the editor's state. The rest is the selection, folds, lint markers, and the data of every extension. The naive editor keeps all of it in one mutable object. Typing calls doc.insert(), which fires change listeners one by one.
The bracket matcher runs first and redraws. Then autocomplete runs and reads the selection, which has not moved yet because the selection listener comes later, so the popup opens at the wrong position. Undo is worse: it must know how to reverse each feature's side effects. Every bug comes from one root: features see the editor half updated.
Decision How do changes flow through the editor?
What we pick: We pick immutable state plus transactions. Our editor is a platform where many extensions written by different teams react to the same keystroke, and a single atomic step is the only way to keep them consistent. It also matches the immutable document chosen above, and it is what lets undo and collaboration live outside the core.
One update cycle, step by step
- The input layer turns a DOM event into a transaction spec:
{ changes, selection, userEvent }. - Filters run. A read-only facet or a change filter can drop or shrink the change, for example to protect a problem's starter code in the online judge.
- Every state field computes its next value from the old value and the transaction: history records the inverse change, lint maps its markers, folds map their ranges.
- The view gets a view update: the old state, the new state, the changes, and flags such as
docChanged,selectionSet, andviewportChanged. - The view redraws only the lines whose content or decorations changed. It batches DOM reads (measuring line heights, cursor coordinates) and DOM writes into separate phases so the browser lays out once per frame, not once per read.
- Update listeners, including the host's
onChange, run last and see a consistent new state.
import { StateField } from "@codemirror/state";
const todoCount = StateField.define<number>({
create: (state) => countTodos(state.doc.toString()),
update(count, tr) {
if (!tr.docChanged) return count; // most transactions: no work
let delta = 0;
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
delta -= countTodos(tr.startState.sliceDoc(fromA, toA));
delta += countTodos(inserted.toString());
});
return count + delta; // a sketch: ignores TODOs split by the edit
},
});
Undo: grouping keystrokes into steps
If each keystroke were its own undo step, undoing a typed word would take 8 presses. If everything were one step, one undo would erase a minute of work. Editors group by three signals. CodeMirror 6's history extension is a good model, and its defaults are worth quoting:
- Time. A change joins the previous group only if it comes within
newGroupDelay, 500 ms by default. A pause starts a new step. - Place. It must touch the range the previous group changed. Typing on line 3 and then on line 40 within 500 ms is still two steps.
- Kind. Typing and deleting (
input.type,delete) can join the group before them. A paste (input.paste) never does, so it always starts a new step, and moving the cursor ends the group. IME composition updates always join, even after a pause, so a composed word is one step. - Depth. At least 100 steps are kept (
minDepth). Each step stores the inverse change set and the selection before it, not a copy of the document.
Collaboration adds one rule. When a remote change arrives, it is marked addToHistory: false, and the history maps its stored inverse changes through it. Undo then reverts only your own edits, at their new positions. The undo-redo guide covers command stacks in general; here it is enough that inverse changes are values that can be mapped like any position.
Check your understanding · Exercise 4
With the default grouping above, which sequences produce exactly two undo steps? Select all that apply.
Cursors and selections: more than one blinking line
The naive plan is to trust the browser's selection: read window.getSelection() whenever you need the cursor. It breaks in three ways: most browsers show only one selection range, but programmers expect 5 cursors after Ctrl+D. The DOM selection points into nodes that the editor deletes on every redraw. And with only 40 lines in the DOM (next chapter), a selection that starts on line 10 and ends on line 9,000 has no DOM node to point at.
So the selection is part of the state, like the document. It is a list of ranges, each with an anchor (where the selection started) and a head (where the cursor is now, the end that moves with Shift+arrow). An empty range, where anchor equals head, is a plain cursor, and one range is the main one. After every transaction the ranges are sorted and overlapping ones are merged, so two cursors that meet become one.
The main range is copied into the real DOM selection so that the IME, the screen reader, and the phone's selection handles all see it. The other ranges are drawn by the editor as absolutely placed div elements in a layer behind the text. Every command runs once per range: CodeMirror's changeByRange takes a function that returns a change and a new range for each one, and maps later ranges through earlier changes.
The goal column
Put the cursor at column 30 of a long line and press Down. The next line has only 5 characters, so the cursor lands at its end. Press Down again onto another long line, and users expect column 30 again, not column 5.
The editor remembers a goal column on the range when vertical movement starts, uses it for every Up and Down, and drops it on any horizontal move or edit. CodeMirror 6 stores it as a horizontal pixel offset rather than a character count, which keeps it right for tabs, wide characters, and proportional fonts.
What is one character?
- Code units versus graphemes. The thumbs up emoji with a skin tone is 4 UTF-16 code units but one visible character, a grapheme cluster. So is a flag, and so is
efollowed by a combining accent. Arrow keys must step over the whole cluster or the cursor lands inside an emoji and the next keystroke corrupts it. UseIntl.Segmenterwithgranularity: "grapheme", or a built-in helper such as CodeMirror'sfindClusterBreak. - Tabs. A tab is one character but jumps to the next multiple of the tab size. Column numbers in the status bar and the goal column must count visual columns, not characters (code below).
- Wide characters. Chinese and Japanese characters are roughly twice as wide as Latin letters in most monospace fonts, but not exactly, and emoji widths vary by font. An editor that computes x as
column * charWidthputs the cursor in the wrong place on those lines. Measure from the DOM (Range.getClientRects()) for any line that is not plain ASCII. - Bidirectional text. A string literal in Arabic or Hebrew runs right to left inside a left to right line. Pressing Right should move the cursor one step to the right on screen, which is a different character order from the stored one. The editor runs the Unicode Bidirectional Algorithm per line to find the runs and moves visually; a selection across a direction change can look like two separate highlights. The rtl-layouts lesson explains the algorithm.
function visualColumn(line: string, offset: number, tabSize = 4): number {
let column = 0;
for (let i = 0; i < offset; i++) {
column = line[i] === "\t"
? column + tabSize - (column % tabSize) // jump to next tab stop
: column + 1;
}
return column;
}
visualColumn("\tif (x)", 1); // 4
visualColumn("ab\tc", 3); // 4: the tab only filled 2 columns
Check your understanding · Exercise 5
A user has three cursors, on lines 2, 5, and 9, and types the letter x. Which statements describe a correct implementation? Select all that apply.
Rendering only the visible lines
The naive view renders one div per line. A 100,000 line file then has well over half a million nodes counting highlight spans: seconds of layout and hundreds of MB. The virtualization lesson and the virtualized-list guide explain windowing in general. An editor adds three twists: line heights are unknown when lines wrap, the cursor and selection can sit in lines that are not rendered, and a single line can be 2 MB long.
Decision What do we draw the text with?
What we pick: We render DOM lines windowed to the viewport. Code is text, and the browser's text engine already handles every script, font, and selection rule we need, plus accessibility. Canvas pays off for fixed grids of cells such as terminals or minimaps, not for an editor whose content must stay readable.
The viewport and the height map
The viewport is the range of the document that gets DOM elements: the visible lines plus a margin, so a quick scroll doesn't show blank space before the next frame. CodeMirror 6 uses a margin of 1,000 pixels above and below. To know which lines fall in a pixel range without rendering them, the editor keeps a height map: a balanced tree over the lines where each node stores the total height of its lines. Finding the line at y = 1,200,000 px is a walk down the tree, O(log n).
Heights start as estimates. Without wrapping, every line is one line height. With wrapping, the estimate is the line length divided by the characters that fit in the width, rounded up, times the line height. When lines enter the viewport, the editor measures their real height and replaces the estimate.
If lines above the visible area turn out taller than estimated, the content below would jump. The editor prevents that with scroll anchoring: it notes the top visible line and its offset before the update, and adjusts scrollTop after measuring so that line stays put.
Top visible line 50,001, wrapping on. DOM holds lines 49,966 to 50,057: 92 elements of 100,000. Visible: 22 lines. Height map total 2,653,240 px estimated. Scroll anchor correction plus 40 px. Edit: no edit: nothing to parse, pick an edit above; background: nothing, colors already right.
Measuring text: grid or real
To place the cursor and to turn a mouse click into a position, the editor converts between offsets and pixels. A monospace grid assumes every character is charWidth wide, so x is column * charWidth. It is fast and right for ASCII code, but wrong for Chinese, emoji, proportional fallback fonts, and bidi text.
Real measurement asks the browser: create a DOM Range over the character and call getClientRects(), or use document.caretPositionFromPoint (or the older, non-standard caretRangeFromPoint where it is missing) for clicks. A good editor uses the grid as a fast path on plain lines and measures the rest, caching results per line until the line changes.
Very long lines and very tall documents
- A minified 2 MB line would be one element with millions of characters and spans. CodeMirror 6, when wrapping is off and a line is longer than a few thousand characters, renders only the part near the visible columns and stands in for the rest with empty gaps of the estimated width. Monaco stops rendering a line after 10,000 characters by default (
stopRenderingLineAfter). - Browsers cap element height: a tall enough scroll area hits a limit in the tens of millions of pixels, lower in some browsers. One million lines at 20 px is 20 million pixels. CodeMirror 6 caps the real DOM height at 7 million pixels and scales positions inside it.
- Cursor in an unrendered line. A selection can end on line 9,000 while lines 1 to 60 are rendered. The real selection lives in the state, so commands such as Shift+Down read the state, never the DOM.
Check your understanding · Exercise 6
A user opens a 60,000 line Markdown file with line wrapping on and scrolls fast to the middle. Text above the viewport then gets measured and is taller than estimated. What should the editor do? Select all that apply.
Syntax highlighting that keeps up with typing
The naive highlighter reruns its regular expressions over the whole document per keystroke and rebuilds every span. Cheap at 300 lines; hundreds of ms at 50,000, and typing stutters. Worse, regexes per line can't know that line 9,000 sits inside a comment that opened on line 8,990. Highlighting needs context from far above, but each keystroke can only afford a few milliseconds.
Decision How do we find out what each piece of text is?
What we pick: We use an incremental parser with a syntax tree (Lezer style) as the base, because one tree serves highlighting, folding, indentation, and autocomplete, and it recovers well from half-typed code. Semantic tokens from a language engine can be layered on later for languages where the host wants them. TextMate grammars remain a fallback for rare languages, through a legacy-mode adapter.
Parsing on a budget
Even an incremental parser cannot finish a 10 MB file in one frame. So parsing is split into time-boxed work. CodeMirror 6's language package is a good concrete model:
- When a transaction changes the document, the parser gets up to 20 ms of work right away, aimed at reaching the end of the viewport. The new tree is ready for the very paint that shows the keystroke.
- If it runs out of time, the view paints with the tree it has. Text past the parsed point is shown plain for a moment.
- The rest happens in the background:
requestIdleCallbackslices of 25 to 100 ms, with pauses between them, and a cap of 3 seconds of background work per 30 seconds so a huge file doesn't drain a laptop battery. - It parses ahead of the viewport only by about 100,000 characters. The tail of a 10 MB file is parsed when you scroll there, or when a feature asks for it with a timeout (
ensureSyntaxTree).
Why not a Web Worker? Tokens would come back a frame or more after the keystroke, so fresh text would flash uncolored. Time-boxed parsing on the main thread avoids that; workers suit slow, project-wide work such as type checking and linting (chapter 8). VS Code made the other trade: since late 2024 its TextMate tokenization runs in a worker by default (editor.experimental.asyncTokenization), accepting a short delay so a slow grammar never blocks input.
From tree to colored spans
import { syntaxTree } from "@codemirror/language";
import { Decoration, type EditorView } from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";
function highlightVisible(view: EditorView) {
const out = new RangeSetBuilder<Decoration>();
const tree = syntaxTree(view.state); // whatever is parsed so far
for (const { from, to } of view.visibleRanges) {
tree.iterate({
from, to,
enter: (node) => {
const cls = classForNode(node.name); // "String" -> "tok-string"
// Clip to the visible range so ranges reach the builder in order.
const start = Math.max(node.from, from), end = Math.min(node.to, to);
if (cls && start < end) out.add(start, end, Decoration.mark({ class: cls }));
},
});
}
return out.finish();
}
Two details matter. First, node types map to abstract highlight tags (string, keyword, comment), and a theme maps tags to classes. Grammars never mention colors, so one theme works for every language. Second, the result is only for the visible ranges: highlighting 50 lines costs the same in a 50 line file as in a 50,000 line one.
Check your understanding · Exercise 7
A user types /* at the top of a 40,000 line file. Which statements are true? Select all that apply.
Extensions, architecture, and the embedding API
The naive editor grows an options object: lineNumbers: true, lint: true, vimMode: true, until there are 80 flags and each new feature is a pull request to the core. The online judge then wants read-only starter code, the review tool wants comment widgets between lines, and the playground wants a color swatch next to every CSS color. None of these fit a flag. The editor needs a way for outside code to add state, drawing, keys, and behavior, with clear rules for who wins when two extensions disagree.
Decision How do host apps add behavior?
What we pick: We pick composable extensions with facets, and ship a preset (basicSetup) so most hosts never see the machinery. Precedence rules settle conflicts, such as the online judge's Tab override against the default keymap, without anyone editing the core.
Four kinds of decoration
| Kind | What it does | Example |
|---|---|---|
| Mark | Adds a class or attributes to a range of text | Syntax colors, search matches, a red squiggle under an error |
| Widget | Inserts an element at a position, not part of the text | Inline type hints, a color swatch, a review comment box between lines |
| Replace | Hides a range and optionally shows a widget instead | A folded function shown as {...} |
| Line | Adds a class to a whole line | Added and removed lines in a diff, the active line highlight |
Decorations are stored in a range set: a sorted, chunked structure of ranges that maps through a change set in one pass. When you type above 300 lint markers, they all shift without 300 updates. Gutters follow the same rule: line numbers, fold arrows, and lint icons are markers per line, computed only for the lines in the viewport.
import { Compartment } from "@codemirror/state";
const language = new Compartment();
const view = new EditorView({
extensions: [basicSetup, language.of([])], // no grammar yet
parent: host,
});
async function setLanguage(name: "python" | "javascript") {
const ext = name === "python"
? (await import("@codemirror/lang-python")).python()
: (await import("@codemirror/lang-javascript")).javascript();
view.dispatch({ effects: language.reconfigure(ext) });
}
Autocomplete, hover, diagnostics, and where they run
Smart features need a language engine that understands the code: the TypeScript compiler, Pyright for Python, clangd for C++. These are big and slow, so they never run inside the keystroke path. There are three homes for them:
- In process, for small things. Completing words from the document, or keywords of a language, is fast enough on the main thread.
- A Web Worker. Monaco runs the TypeScript language service in a worker and talks to it by messages. The worker holds a copy of the document, updated with each change, so only the changes cross the boundary.
- A language server over a WebSocket. The Language Server Protocol (LSP) is JSON-RPC: the editor sends
textDocument/didChange, askstextDocument/completion, and receivestextDocument/publishDiagnostics. Any of the hundreds of existing servers works, running next to the user's code in a container. LSP positions are line and character, counted in UTF-16 units by default, which happens to match JavaScript strings.
The figure's failure path is the important part: replies can come back for an older document. Every request carries the document version, and the editor checks it when a reply arrives. If the user kept typing, it either maps the reply's positions through the changes since then (fine for diagnostics) or drops it and asks again (better for completions, which depend on the word being typed).
Requests that are no longer needed are canceled ($/cancelRequest in LSP) so a slow server doesn't build a queue. Lint runs after a pause: CodeMirror's lint extension waits 750 ms after the last change by default, the same idea as the debounce-throttle lesson.
Check your understanding · Exercise 8
The online judge wants Tab to insert two spaces, while the default keymap uses Tab to indent the line. Both are installed. What makes the judge's binding win? Select all that apply.
The architecture, part by part
With the hard parts and the extension model decided, the pieces fall into clear boxes. Each boundary is drawn where the rate of change or the cost differs: input changes per keystroke, the view per frame, the parser on a time budget, the language engine on its own thread.
| Part | Owns | Why it is separate |
|---|---|---|
| Input layer | The contenteditable element, beforeinput, composition, clipboard, the DOM observer | All browser quirks live here, so nothing else touches raw DOM events |
| Editor state | Document tree, selection, state fields, facet values | Pure values: testable without a DOM, snapshots for undo and collaboration |
| View | Viewport, height map, line DOM, cursor layer, measuring | The only part that reads or writes layout, in batched phases |
| Language | Incremental parser, syntax tree, highlight tags | Runs on a time budget, separate from input |
| Extensions | Keymaps, decorations, gutters, panels, lint, autocomplete | Host apps add and swap them without forking |
| Language client | Worker or WebSocket link to a language engine | Slow, project-wide work off the main thread |
| Host wrapper | Props, events, lifecycle in React or another framework | Keeps framework re-renders from rebuilding the editor |
The component's interface
type CodeEditorProps = {
defaultValue?: string; // read once at mount
value?: string; // controlled: synced by a minimal diff
onChange?: (update: { getValue: () => string; changes: ChangeSet }) => void;
language?: "javascript" | "typescript" | "python" | "css" | "html";
readOnly?: boolean;
extensions?: Extension[]; // host additions, compared by identity
theme?: "light" | "dark" | "high-contrast";
ariaLabel: string; // required: what the editor is for
onReady?: (api: EditorApi) => void;
};
type EditorApi = {
focus(): void;
getValue(): string; // builds the string only when asked
setSelection(from: number, to?: number): void;
revealLine(line: number): void;
};
Three choices in that interface are deliberate. onChange passes a getter, not the string: building a 10 MB string on every keystroke costs milliseconds and garbage, while most hosts only need it on save. ariaLabel is required, because an unlabeled editor is announced as just "edit text". Uncontrolled is the default, because the editor already owns its state; the host reads it when it needs it.
Wrapper pitfalls in React
- Re-creating the editor on every render. An effect that depends on an inline
extensionsarray sees a new array each render, destroys the view, and builds a new one: undo history, cursor, scroll, and focus are lost, and each rebuild costs tens of ms. Create the view once, and push prop changes in with compartmentreconfigureeffects. - The controlled value loop.
onChangecallssetCode, React re-renders, the wrapper sees a newvalueand callssetValue, which replaces the whole document. The cursor jumps to 0, undo history resets, and an IME composition in progress is destroyed. The fix: when the incomingvalueequals the text the editor already holds, do nothing. When it really differs (the host formatted the code), dispatch only the changed middle part, found by comparing common prefix and suffix, so the cursor maps through it. - Double mounting in development. React's Strict Mode runs effects twice. Without
view.destroy()in the cleanup, you get two editors stacked in one box. - Server rendering. The editor needs the DOM. Render the code as a
prewith the same font and line height on the server, then swap in the editor after a dynamic import. The page shows the code at once and nothing shifts when the editor arrives.
value against the editor's text, and dispatch only when it really differs, as a minimal change.const External = Annotation.define<boolean>(); // marks changes that came from props
function CodeEditor({ value, onChange, ariaLabel }: CodeEditorProps) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const synced = useRef(value); // last value known to match the document
const onChangeRef = useLatest(onChange); // avoids re-creating on new callbacks
useEffect(() => {
view.current = new EditorView({
parent: host.current!,
doc: value ?? "",
extensions: [
basicSetup,
EditorView.contentAttributes.of({ "aria-label": ariaLabel }),
EditorView.updateListener.of((u) => {
if (!u.docChanged || u.transactions.some((t) => t.annotation(External))) return;
synced.current = undefined; // the text changed inside the editor
onChangeRef.current?.({ getValue: () => u.state.doc.toString(), changes: u.changes });
}),
],
});
return () => view.current?.destroy();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const v = view.current;
if (!v || value === undefined || value === synced.current) return;
if (value !== v.state.doc.toString()) {
v.dispatch({ changes: minimalDiff(v.state.doc, value), annotations: External.of(true) });
}
synced.current = value;
}, [value]);
return <div ref={host} />;
}
Check your understanding · Exercise 9
A host passes extensions={[lineNumbers(), myLint]} inline and the editor loses its undo history every time the parent re-renders. Which fixes work? Select all that apply.
Large files, search, and bundle size
Large files: decide the limits up front
Everything so far keeps the cost of a keystroke independent of file size. But some features are inherently whole-file: folding ranges for every function, bracket pair colors, a minimap, word-based completion that indexes every identifier. On a 50 MB log they freeze the tab, and the answer is not cleverness but thresholds decided in the design. VS Code does exactly this: above 20 MB or 300,000 lines it switches to a large file mode and turns off tokenization and other heavy features, unless the user turns editor.largeFileOptimizations off.
| File size | What stays on | What turns off | Why |
|---|---|---|---|
| Under 1 MB (almost all code) | Everything | Nothing | Whole-file work costs a few ms here |
| 1 to 10 MB | Editing, viewport highlighting, search | Bracket pair colors, minimap, word completion, background parse beyond the parse-ahead window | These scan the whole file; the parser's budget still covers what you see |
| 10 to 50 MB | Editing, plain text, search in chunks | Highlighting, folding, lint, language server sync | Sending 50 MB to a worker or server on open costs seconds |
| Over 50 MB, or lines over 1 MB | Read-only view, or an "open anyway" prompt | Editing by default | The text alone is a large share of a phone's memory budget for a tab |
Minified one-line files need their own rule, since line count says nothing about them: measure the longest line on load. Above a few thousand characters, render the line in windowed slices (chapter 6), cap tokenization per line (VS Code and Monaco skip lines over 20,000 characters by default, set by editor.maxTokenizationLineLength), and offer a "format document" button that pretty-prints it into a normal file.
Search and replace
- Never build the whole string. A search cursor iterates over the document tree's chunks, so a search in a 10 MB document allocates almost nothing. Plain text search can match across chunk borders by keeping a small tail buffer. Regular expressions run line by line, with a separate, slower cursor for patterns that span lines.
- Highlight what is visible, count the rest lazily. Matches in the viewport become mark decorations at once. The total count ("3 of 1,284") comes from a background pass in idle slices and can show "1,000+" until it finishes.
- Guard against runaway regexes. A pattern like
(a+)+bon a 1 MB minified line can backtrack for seconds. Search in slices with a time check between lines, and offer to stop. - Replace all is one transaction. Thousands of replacements become one change set, one undo step, and one redraw. Mapping the selection through it keeps the cursor where the user left it.
- Debounce the search box by about 100 to 200 ms on big files (see the debounce-throttle lesson).
Bundle size and loading
The editor must not slow the host page. We measured the published CodeMirror 6 packages that a basic setup with JavaScript pulls in, each minified and gzipped without tree shaking. @codemirror/view 6.43 alone is about 63 KB. The whole set (state, view, commands, language, autocomplete, search, lint, the Lezer runtime, and the JavaScript grammar) is about 185 KB, inside our 200 KB target.
Monaco is a different scale. In version 0.52 its main editor file is about 3.8 MB minified and 950 KB gzipped, and each language service runs in a separately loaded worker; the TypeScript worker alone adds about 1.3 MB gzipped. Your bundler and the languages you include move all of these numbers, so measure your own build.
- Don't put the editor on the critical path. Render a
prewith the code first (a fast Largest Contentful Paint, no layout shift), thenimport()the editor after first paint or when the user focuses the box. - One chunk per language. Load the Python grammar only when a Python file opens, through the compartment swap from chapter 8. The code-splitting lesson covers chunking strategy.
- Read-only views stay light. The review page's 30 small editors need highlighting and line numbers, not autocomplete, lint, or history. A read-only preset can skip those extensions.
- Preload on intent. Hovering the "Edit" button for 100 ms can start the download so the editor is ready on click.
Check your understanding · Exercise 10
A user opens a 30 MB JSON export in the review tool. Which behaviors match a sound design? Select all that apply.
Accessibility and theming
A code editor fails accessibility in quiet ways, and each has a concrete fix. A screen reader announces "blank" on every line because the text is drawn in a canvas. Tab inserts indentation, so a keyboard user who tabs into the editor can never tab out. Errors are shown only as red squiggles, which a color blind user cannot see and a screen reader never mentions.
Screen readers need real text
- The text must be in the accessibility tree. With a
contenteditablecontent element markedrole="textbox",aria-multiline="true", and anaria-label, screen readers read lines, words, and characters as the cursor moves, using their normal text commands. - The hidden textarea needs help. A textarea holding one character gives a screen reader nothing to read. Monaco has an accessibility mode (
accessibilitySupport, detected automatically on some platforms) that fills the textarea with the lines around the cursor, so arrow keys read real text; Alt+F1 opens its accessibility help. - Windowing has a cost. Only rendered lines are in the DOM, so a screen reader's "read all" stops at the edge of the viewport. Keep the margin generous and make sure the cursor's line is always rendered.
- Announce what sighted users see at a glance. Put line and column in a status region, and add a command that reads the diagnostic at the cursor ("Error: missing semicolon, line 12") through a polite
aria-liveregion. Make lint panels and autocomplete lists real lists witharia-activedescendant, the combobox pattern from the autocomplete guide.
The Tab key and the keyboard trap
WCAG success criterion 2.1.2, No Keyboard Trap, says that if keyboard focus can move into a component, it must be able to move out with the keyboard, and if that needs more than the standard keys, the user must be told how. Programmers want Tab to indent. Both are reasonable, so editors offer an escape hatch.
CodeMirror 6 doesn't bind Tab by default at all. If the host adds indentWithTab, pressing Escape makes the next Tab move focus out (for 2 seconds), and Ctrl+M (Shift+Alt+M on macOS, in the default keymap) toggles a mode where Tab always moves focus. Monaco's equivalent is Ctrl+M (Ctrl+Shift+M on macOS) to toggle "Tab moves focus". Our editor binds Tab to indent only when the host opts in, and then shows the escape hatch in a visible hint on first focus, as the focus-management and keyboard-navigation lessons recommend.
Think first
The online judge wants Tab to indent because users type code there for 45 minutes. The review page has 30 small read-only editors. Should both bind Tab the same way?
No. In the judge, Tab to indent is expected, so bind it and show the Escape then Tab hint and the Ctrl+M toggle in the editor's help.
In read-only review editors nobody indents, so Tab must move focus to the next editor or comment. Binding Tab there would make the user tab through 30 traps to reach the submit button.
Contrast, forced colors, and motion
- Token colors are text, so each needs 4.5:1 contrast with the background (WCAG 1.4.3). Comments in pale gray fail this in many popular themes.
- Never use color alone: an error is a wavy underline plus a gutter icon plus a message on hover and in the lint panel.
- Windows high contrast mode sets
forced-colors: active. It removes gradient backgrounds, so squiggles drawn with gradients vanish, and SVG squiggles keep a fixed color the user did not choose. Usetext-decorationor borders with system colors such asCanvasTextandHighlight, and ship a high contrast theme. - With
prefers-reduced-motion: reduce, turn off smooth scrolling for go to line and search jumps, and consider a steady cursor instead of a blinking one.
Theming with CSS variables
Grammars produce highlight tags, the highlighter maps tags to classes, and the theme gives classes colors. If those colors are CSS custom properties (--cm-keyword, --cm-bg, --cm-selection), switching from light to dark is one attribute change on the host's root element: no editor rebuild, no re-highlight, and the host's design system tokens can feed the editor directly. CodeMirror injects its base styles in a style element; under a strict Content Security Policy, pass the page's nonce through the EditorView.cspNonce facet, or the styles are blocked.
.cm-editor {
background: var(--cm-bg);
color: var(--cm-fg);
}
.tok-keyword { color: var(--cm-keyword); }
.tok-string { color: var(--cm-string); }
.tok-comment { color: var(--cm-comment); font-style: italic; }
[data-theme="dark"] {
--cm-bg: #1e1e2e; --cm-fg: #e4e4ef;
--cm-keyword: #c792ea; --cm-string: #a5d6a7; --cm-comment: #9aa0b4;
}
@media (forced-colors: active) {
.cm-lintRange-error { background-image: none; text-decoration: wavy underline CanvasText; }
}
Check your understanding · Exercise 11
Which changes would make an editor that binds Tab to indent meet WCAG 2.1.2? Select all that apply.
Running it in the interview
This question has more depth than any interview can cover, so the skill is choosing. Show you know the whole pipeline in two minutes, then go deep on the two or three parts the interviewer cares about. A good 60 minute plan:
| Minutes | What you do | What you draw |
|---|---|---|
| 0 to 6 | Scope: hosts, devices and IME, file sizes, smart features, read-only instances, collaboration | A list of requirements with numbers |
| 6 to 12 | The pipeline: input, transaction, state, view, parser, extensions | The keystroke pipeline from chapter 2 |
| 12 to 22 | Input surface and IME, then the document model with position mapping | The surfaces comparison, a piece table or line tree |
| 22 to 32 | State and transactions, undo grouping, multiple selections | The transaction flow |
| 32 to 44 | Viewport and height map; incremental parsing on a time budget | The viewport with margin, the parser state machine |
| 44 to 52 | Extensions, language features over a worker or LSP, the embedding API | The architecture diagram |
| 52 to 60 | Large files, accessibility, bundle size; follow-up questions | The thresholds table |
If the interviewer steers early, follow them, but draw the pipeline in one minute first so they see where their part sits. Expect pushes on IME, position mapping, and the controlled value loop: those are the bugs editor teams actually fix.
Follow-up questions and strong answers
Think first
How would you add real-time collaboration to this editor?
The design already has the hooks: changes are values, positions map through changes, and history can skip remote changes. A collaboration extension sends local change sets to a server and applies remote ones as transactions marked as remote and not added to history.
Conflict handling is OT with a central server (CodeMirror's collab package works this way) or a CRDT such as Yjs bound to the editor. Remote cursors are decorations mapped through each change. The google-docs guide covers the algorithms.
Think first
Why not render with canvas, like some fast native editors do with the GPU?
Canvas wins when painting is the bottleneck. In a web editor that only draws 50 lines, it isn't: layout of a few thousand spans takes a few ms.
Canvas gives up what the rendering chapter listed: shaping, bidi, IME placement, selection, find in page, and the accessibility tree. VS Code's experimental WebGPU renderer shows the cost, since it still falls back to the DOM for lines it can't draw. Canvas fits the minimap, not the text.
Think first
A user pastes 5 MB into an empty editor. Walk through what happens.
The paste event gives text/plain; we normalize line breaks and dispatch one transaction with userEvent: "input.paste", one undo step. The document tree is built from the lines in O(n) once.
The view estimates heights for all lines and renders only the viewport. The parser gets 20 ms for the viewport, then idle slices. If the size crosses a threshold, heavy features switch off with a banner. onChange fires once with a getter, not a 5 MB string.
Common mistakes
- Answering with a library name and stopping. The interviewer asked how it works.
- Designing input with
keydownonly, which breaks IME, phone keyboards, and dictation. - Storing the document as one string, or an array of lines, without discussing the cost at 100,000 lines.
- Re-highlighting the whole file per keystroke, or moving highlighting to a worker without noticing the extra frame of delay.
- Forgetting that decorations, cursors, and diagnostics must move when text is inserted before them.
- Making Tab indent with no way out of the editor.
Summary
- An editor is a pipeline: input surface, transaction, immutable state, windowed view, with incremental parsing and extensions on the side. Name it first, then go deep.
- Text input is an OS service. Use a real editable element (contenteditable, a hidden textarea, or EditContext), respect composition events, and never run commands on keys with
isComposing. - Store text in a balanced tree (line chunks or a piece tree) so edits and line lookups cost O(log n), and describe edits as change sets that map every position, decoration, and cursor.
- Immutable state plus transactions keeps every extension consistent and makes undo and collaboration add-ons. Undo groups by time, place, and kind of edit.
- Render only the viewport plus a margin, track heights in a height map of estimates corrected by measurement, and anchor the scroll when estimates change.
- Highlight with an incremental parser on a time budget: the viewport within the keystroke's frame, the rest in idle slices. Slow language intelligence goes to a worker or a language server.
- Decide large-file thresholds and Tab escape hatches in the design, keep the core small with lazy language chunks, and give hosts an uncontrolled-first API that never re-creates the editor.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- CodeMirror System Guide State, transactions, facets, precedence, and position mapping, from the library's author.
- Text Buffer Reimplementation (VS Code blog) Why the array of lines failed and how the piece tree works, with benchmarks.
- Lezer parser system The incremental parser behind CodeMirror 6 highlighting and folding.
- Tree-sitter documentation Incremental parsing in C and WebAssembly, and its edit-then-reparse API.
- Monaco Editor repository The VS Code editor for the browser: workers, languages, and integration samples.
- MDN: EditContext API How custom editors receive text input without a hidden textarea.
- MDN: compositionstart event The start of IME composition, with links to update and end.
- W3C Input Events Level 2 The
beforeinputevent and everyinputTypevalue. - Understanding WCAG 2.1 SC 2.1.2: No Keyboard Trap The rule behind the Tab escape hatch.
- Language Server Protocol 3.17 specification Messages for completion, hover, diagnostics, and position encodings.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- 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.
- 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.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- 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.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it.
Try next
- Design an online code playground like CodePenHard
- Design an online judge like LeetCodeHard
- Design a code review diff viewerHard
- Design a rich text editorHard
- Design a markdown editor with live previewMedium
- Design a web-based terminalHard
- Design a collaborative document editor like Google DocsHard
- Design a virtualized listHard