When building the Sema playground, I needed syntax highlighting for the code editor. Reaching for CodeMirror or Monaco felt like overkill for a single-file playground that already weighed in at ~3000 lines. Instead, I used a simple overlay technique: a transparent <textarea> stacked on top of a <pre> element that renders the highlighted HTML. No libraries, no dependencies, and it works surprisingly well.
The core idea
The trick is to layer two elements on top of each other inside a positioned container:
- A
<pre>element at the bottom that renders syntax-highlighted HTML - A
<textarea>on top with fully transparent text, so you see the highlighted version underneath while still typing into a native input
The textarea handles all the editing—cursor, selection, keyboard shortcuts, undo/redo—while the <pre> handles all the visual rendering. Every time the textarea content changes, you re-tokenize and re-render the highlighted HTML into the <pre>.
The HTML
The markup is minimal:
<div class="editor-wrap">
<textarea id="editor" spellcheck="false"></textarea>
<pre class="editor-highlight" id="editor-highlight" aria-hidden="true"></pre>
</div>
The <pre> is marked aria-hidden="true" since it's purely decorative—screen readers should interact with the textarea.
The CSS
This is where the magic happens. Both elements need identical typography and positioning so the text lines up perfectly:
.editor-wrap {
position: relative;
overflow: hidden;
}
/* Shared properties — these MUST match exactly */
.editor-highlight,
textarea#editor {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 1.25rem;
font-family: "JetBrains Mono", monospace;
font-size: 13px;
line-height: 1.65;
tab-size: 2;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
border: none;
margin: 0;
}
/* The highlight layer: visible text, no interaction */
.editor-highlight {
pointer-events: none;
color: #d8d0c0;
background: #0a0a0a;
z-index: 0;
overflow: auto;
}
/* The textarea: invisible text, handles all input */
textarea#editor {
color: transparent;
caret-color: #c8a855; /* cursor is still visible */
background: transparent;
outline: none;
resize: none;
z-index: 1;
-webkit-text-fill-color: transparent;
}
/* Selection styling — visible since the text itself is transparent */
textarea#editor::selection {
background: #c8a855;
color: #0c0c0c;
-webkit-text-fill-color: #0c0c0c;
}
The critical parts:
font-family,font-size,line-height,padding,white-space,tab-sizemust be identical on both elements, otherwise the text drifts out of alignment.-webkit-text-fill-color: transparentis needed on WebKit/Blink browsers becausecolor: transparentalone doesn't hide text in textareas on some browsers.caret-colorkeeps the cursor visible even though the text is invisible.pointer-events: noneon the highlight layer lets clicks pass through to the textarea.z-indexensures the textarea sits above the highlight layer for input events.
The tokenizer
You need a function that breaks the source code into tokens. Here's a simplified version of the tokenizer I used for Sema (a Lisp dialect):
const KEYWORDS = new Set([
"define",
"lambda",
"fn",
"if",
"cond",
"let",
"let*",
"begin",
"and",
"or",
"not",
"set!",
"map",
"filter",
"foldl",
"for-each",
"apply",
]);
function tokenize(code) {
const tokens = [];
let i = 0;
while (i < code.length) {
// Comments: ; to end of line
if (code[i] === ";") {
const start = i;
while (i < code.length && code[i] !== "\n") i++;
tokens.push({ type: "comment", text: code.slice(start, i) });
}
// Strings: "..."
else if (code[i] === '"') {
const start = i;
i++;
while (i < code.length && code[i] !== '"') {
if (code[i] === "\\" && i + 1 < code.length) i++;
i++;
}
if (i < code.length) i++;
tokens.push({ type: "string", text: code.slice(start, i) });
}
// Parentheses
else if ("()[]{}".includes(code[i])) {
tokens.push({ type: "paren", text: code[i] });
i++;
}
// Whitespace
else if (/\s/.test(code[i])) {
const start = i;
while (i < code.length && /\s/.test(code[i])) i++;
tokens.push({ type: "ws", text: code.slice(start, i) });
}
// Words
else {
const start = i;
while (i < code.length && !/[\s()[\]{}"`;]/.test(code[i])) i++;
const word = code.slice(start, i);
if (/^-?\d+(\.\d+)?$/.test(word)) {
tokens.push({ type: "number", text: word });
} else if (KEYWORDS.has(word)) {
tokens.push({ type: "keyword", text: word });
} else {
tokens.push({ type: "plain", text: word });
}
}
}
return tokens;
}
The tokenizer doesn't need to build an AST or understand the language grammar. It just classifies chunks of text into categories—comments, strings, keywords, numbers, parentheses, and everything else. This is enough for visual highlighting.
Rendering the highlights
Convert tokens to HTML and inject them into the <pre>:
function escapeHtml(s) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function highlight(code) {
if (!code) return "\n";
const tokens = tokenize(code);
let html = "";
for (const t of tokens) {
const escaped = escapeHtml(t.text);
if (t.type === "ws" || t.type === "plain") {
html += escaped;
} else {
html += `<span class="hl-${t.type}">${escaped}</span>`;
}
}
// A trailing newline won't render in <pre> without this
if (code.endsWith("\n")) html += " ";
return html;
}
The trailing space fix is a subtle but important detail: if the code ends with \n, the <pre> won't render that final empty line, causing the highlight layer to be one line shorter than the textarea. Adding a space forces it to render.
Wiring it up
Connect the textarea to the highlight function and keep scroll positions in sync:
const editorEl = document.getElementById("editor");
const hlEl = document.getElementById("editor-highlight");
let hlRaf = 0;
function scheduleHighlight() {
cancelAnimationFrame(hlRaf);
hlRaf = requestAnimationFrame(() => {
hlEl.innerHTML = highlight(editorEl.value);
});
}
function syncScroll() {
hlEl.scrollTop = editorEl.scrollTop;
hlEl.scrollLeft = editorEl.scrollLeft;
}
editorEl.addEventListener("input", scheduleHighlight);
editorEl.addEventListener("scroll", syncScroll);
// Initial highlight
scheduleHighlight();
requestAnimationFrame debounces the re-renders so you're not re-tokenizing on every keystroke during fast typing.
Scroll syncing is essential—without it, the highlighted text and the textarea cursor will drift apart as soon as the content overflows.
The highlight styles
Style each token type however you like:
.hl-comment {
color: #5a5448;
font-style: italic;
}
.hl-string {
color: #a8c47a;
}
.hl-keyword {
color: #c8a855;
}
.hl-number {
color: #d19a66;
}
.hl-paren {
color: #6a6258;
}
Bonus: Tab and Shift+Tab support
By default, Tab moves focus away from the textarea. Override it to insert spaces, and handle Shift+Tab to dedent:
editorEl.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
e.preventDefault();
const v = editorEl.value;
const start = editorEl.selectionStart;
const end = editorEl.selectionEnd;
const isDedent = e.shiftKey;
const ls = v.lastIndexOf("\n", start - 1) + 1;
if (start === end) {
// No selection: insert or remove spaces at cursor
if (!isDedent) {
editorEl.setRangeText(" ", start, end, "end");
} else {
let rm = v.startsWith(" ", ls) ? 2 : v.charAt(ls) === " " ? 1 : 0;
if (rm) {
editorEl.setRangeText("", ls, ls + rm, "preserve");
editorEl.setSelectionRange(
Math.max(ls, start - rm),
Math.max(ls, start - rm),
);
}
}
} else {
// Selection: indent/dedent all selected lines as a block
const endAdj = end > start && v[end - 1] === "\n" ? end - 1 : end;
const le = v.indexOf("\n", endAdj);
const blockEnd = le === -1 ? v.length : le;
const block = v.slice(ls, blockEnd);
const replacement = isDedent
? block.replace(/^ {1,2}/gm, "")
: block.replace(/^/gm, " ");
editorEl.setRangeText(replacement, ls, blockEnd, "select");
}
scheduleHighlight();
}
});
When text is selected, we expand the range to full lines and apply a regex replacement across the whole block. Using "select" as the last argument keeps the modified lines selected afterward, so you can press Tab repeatedly to increase indentation.
Note that we call scheduleHighlight() directly instead of dispatching an input event. Once you add a custom undo stack (next section), dispatching input here would cause it to record a duplicate entry since the undo class also listens on input.
A custom undo stack
The browser's native undo history is fragile. Assigning textarea.value clears it entirely, and even setRangeText() behaves inconsistently across browsers for programmatic edits like indent/dedent. The reliable solution is to manage your own undo stack.
The idea is simple: store snapshots of { value, selectionStart, selectionEnd }, intercept Cmd+Z / Ctrl+Z, and restore from the stack instead of relying on the browser.
class TextareaUndo {
constructor(textarea, { max = 200, mergeDelay = 600, onChange = null } = {}) {
this.ta = textarea;
this.max = max;
this.mergeDelay = mergeDelay;
this.onChange = onChange;
this.stack = [this._read()];
this.index = 0;
this._applying = false;
this._inTransaction = 0;
this._suppress = false;
this._lastInputType = null;
this._lastPushAt = 0;
this._lastKind = null;
this._composing = false;
this._forceNew = false;
textarea.addEventListener("beforeinput", (e) => {
this._lastInputType = e.inputType || null;
});
textarea.addEventListener("compositionstart", () => {
this._composing = true;
});
textarea.addEventListener("compositionend", () => {
this._composing = false;
this._forceNew = true;
});
textarea.addEventListener("input", () => {
if (this._applying || this._suppress || this._inTransaction || this._composing)
return;
this._record();
});
textarea.addEventListener("keydown", (e) => {
const mod = e.metaKey || e.ctrlKey;
if (mod && !e.altKey && e.key.toLowerCase() === "z") {
e.preventDefault();
e.shiftKey ? this.redo() : this.undo();
} else if (mod && !e.altKey && e.key.toLowerCase() === "y") {
e.preventDefault();
this.redo();
}
});
}
_read() {
return {
value: this.ta.value,
start: this.ta.selectionStart ?? 0,
end: this.ta.selectionEnd ?? 0,
};
}
undo() {
if (this.index > 0) {
this.index--;
this._apply(this.stack[this.index]);
}
}
redo() {
if (this.index < this.stack.length - 1) {
this.index++;
this._apply(this.stack[this.index]);
}
}
transact(fn) {
this._inTransaction++;
try {
fn();
} finally {
this._inTransaction--;
if (this._inTransaction === 0) this._record(true);
}
}
reset() {
this.stack = [this._read()];
this.index = 0;
this._lastPushAt = 0;
this._lastKind = null;
}
_record(forceNew = false) {
const next = this._read();
const cur = this.stack[this.index];
if (cur.value === next.value && cur.start === next.start && cur.end === next.end)
return;
const now = performance.now();
const it = this._lastInputType;
const kind = it?.startsWith("insert")
? "insert"
: it?.startsWith("delete")
? "delete"
: "other";
const forcedByType =
it === "insertFromPaste" || it === "insertFromDrop" || it === "deleteByCut";
let merge = false;
if (!forceNew && !this._forceNew && !forcedByType) {
merge =
now - this._lastPushAt <= this.mergeDelay &&
kind === this._lastKind &&
cur.start === cur.end &&
next.start === next.end &&
(kind === "insert" || kind === "delete");
}
this._forceNew = false;
if (merge) {
this.stack[this.index] = next;
} else {
this.stack.splice(this.index + 1);
this.stack.push(next);
this.index++;
if (this.stack.length > this.max) {
const overflow = this.stack.length - this.max;
this.stack.splice(0, overflow);
this.index = Math.max(0, this.index - overflow);
}
}
this._lastPushAt = now;
this._lastKind = kind;
}
_apply(state) {
this._applying = true;
this.ta.value = state.value;
this.ta.setSelectionRange(state.start, state.end);
if (this.onChange) this.onChange();
this._applying = false;
}
}
How it works
Snapshots, not diffs. Each undo entry stores the full textarea value and cursor position. This is dead simple and works reliably. For a playground where files are a few hundred lines, the memory cost is negligible.
Keystroke merging. Typing "hello" shouldn't create 5 undo entries. The stack merges consecutive edits of the same kind (insertions or deletions) within a 600ms window, as long as the cursor is a simple caret (no selection). Paste, cut, and drop always create their own entry.
IME composition. During IME input (e.g. typing CJK characters), intermediate states are suppressed until compositionend fires. Without this, you'd get noisy undo steps for each composition update.
Transactions. The transact() method lets you wrap multi-step operations (like block indent) into a single undo entry. During a transaction, input events are ignored and a single snapshot is recorded when the transaction completes.
Wiring it up with Tab/Shift+Tab
const editorUndo = new TextareaUndo(editorEl, { onChange: scheduleHighlight });
editorEl.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
e.preventDefault();
editorUndo.transact(() => {
// ... indent/dedent logic from above ...
});
scheduleHighlight();
}
});
The transact() call ensures the entire indent or dedent operation—regardless of how many setRangeText() calls happen inside—becomes a single undo step.
Tradeoffs
This approach works great for playgrounds, small editors, and situations where you don't want the weight of a full editor library. But it has limits:
- No line numbers. You'd need to add a separate gutter element and keep it in sync.
- No code folding, autocomplete, or multi-cursor. You get what the browser's textarea gives you, plus highlighting.
- Performance ceiling. Re-tokenizing the entire document on every keystroke works fine for files under a few thousand lines. Beyond that you'd want incremental tokenization.
For anything more complex, reach for CodeMirror 6 or Monaco. But for a focused tool where you control the language and the file sizes are small, this overlay technique is hard to beat for simplicity.
