GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Code Editor JavaScript: 9 UI Parts, No Library

Code editor javascript is the plain way to get coloring, line numbers, and closing brackets into a page: nine parts, no library, just a textarea and a

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is not popularity — it is the order in which one input box turns into an editor. Color lands on the glyphs first (01), numbers line up beside them (02), brackets start following your hands (03), and a selection learns to move as a block (04). Once the file is longer than the box, you need a picture of the whole thing (05) and a way to reach a string inside it (06). Then the box starts answering back: it finishes the word you began (07), underlines what will not parse (08), and finally changes clothes for the hours you actually work (09). Each one is a text editor using html css js and nothing else — the real input is always a <textarea>, and the color is painted by a <pre> sitting underneath it. Next door in the same embed pillar sit Terminal UI Design: 9 Copy-Paste CLI Parts, where command output rather than input is the subject, and i18n JSON Checks: 9 Ways to Catch Missing Keys, which inspects language-pack JSON instead of code.

01Live syntax highlight

A textarea with transparent glyphs sits over a <pre> in identical monospace metrics, and every keystroke repaints the layer underneath — the code input a docs site can ship.

Live syntax highlight
Open in a new tab

01 · Live syntax highlight — Measured area 25.074% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

-webkit-text-fill-colorwhite-space: prescrollTop

Four regex rules, earliest wins

// Four tokenizer rules. The earlier rule wins — this order is what keeps a hex color inside a comment from being caught as a number
var RULES = [
  { cls: 'c', re: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g },
  { cls: 's', re: /"[^"\n]*"|'[^'\n]*'/g },
  { cls: 'n', re: /#[0-9a-fA-F]{3,8}\b|\b\d+(?:\.\d+)?(?:px|ms|s|%|em|rem|fr)?\b/g },
  { cls: 'k', re: /--[a-zA-Z][\w-]*|\.[a-zA-Z][\w-]*|@[a-z-]+|\b[a-z-]+(?=\s*:)/g }
];

A range already claimed is off limits to every later rule, so a line that opens with /* and carries #ff4d1f comes back as a single comment span rather than a comment plus a color.

One block of shared metrics

%layer {
  box-sizing: border-box; margin: 0; padding: $sp-2;
  border: 0; border-radius: $r-sm;
  font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
  letter-spacing: 0; tab-size: 2;
  white-space: pre; overflow: auto; overscroll-behavior: contain;
}

Family, size, line height, padding, letter spacing, and tab width live in one placeholder that both layers extend, which is why a computed-style diff of eight properties came back empty and a textarea scrolled to 120 put the pre at 120 as well.

02Line number gutter

The gutter grows and shrinks with the line count while only the caret's line lights up, which is what an editor needs the moment it has to point at a failing line.

Line number gutter
Open in a new tab

02 · Line number gutter — Measured area 24.353% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

counter-resetselectionStarttranslateY

Numbers that the stylesheet counts

// Redraw the gutter whenever the line count changes — the numbering itself is done by the counter-reset counter in the scss
function render() {
  var lines = ta.value.split('\n');
  var nums = '';
  lines.forEach(function () { nums += '<span class="gt__num"></span>'; });
  gtrack.innerHTML = nums + '<span class="gt__gcur"></span>';
  gcurEl = gtrack.querySelector('.gt__gcur');
  hl.innerHTML = lines.map(function (l) {
    return '<span class="gt__ln">' + (esc(l) || ' ') + '</span>';
  }).join('');
  countEl.textContent = LBL_COUNT.replace('{n}', lines.length);
  update();
}

JavaScript stamps one empty span per line and never writes a digit, and typing two more lines into the six-line sample moved the gutter from 6 numbers to 8.

Caret line from selectionStart

// Work out which line the caret is on from the text before selectionStart, then move both bands by the same value
function update() {
  var idx = ta.value.slice(0, ta.selectionStart).split('\n').length - 1;
  var y = idx * lineH();
  curEl.style.transform = 'translateY(' + y + 'px)';
  if (gcurEl) gcurEl.style.transform = 'translateY(' + y + 'px)';
  sync();
}

Both bands take the same translateY, so scrolling the text to 40 leaves the gutter track at matrix(1, 0, 0, 1, 0, -40) and the two columns stay locked.

03Auto-close brackets

Typing an opening bracket inserts its partner and leaves the caret between the two, which is the difference between a snippet box you type in and one you fight.

Auto-close brackets
Open in a new tab

03 · Auto-close brackets — Measured area 7.31% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

setSelectionRangepreventDefaultkey

Insert, step over, delete the pair

  var over = s === t && v.charAt(s) === k && (PAIRS[k] === k || ')}]'.indexOf(k) >= 0);
  if (PAIRS[k] && !over) {
    // Opening half — insert the closing partner too and let setSelectionRange leave the caret between them
    e.preventDefault();
    var inner = v.slice(s, t);
    ta.setRangeText(k + inner + PAIRS[k], s, t, 'end');
    ta.setSelectionRange(s + 1, s + 1 + inner.length);
    setState('Pair added');
  } else if (over) {
    // Already closed at this spot — insert nothing, just move the caret past it
    e.preventDefault();
    ta.setSelectionRange(s + 1, s + 1);
    setState('Step over');
  } else if (k === 'Backspace' && s === t && s > 0 && PAIRS[v.charAt(s - 1)] === v.charAt(s)) {
    // Backspace with the caret inside an empty pair — delete both characters together
    e.preventDefault();
    ta.setRangeText('', s - 1, s + 1, 'end');
    ta.setSelectionRange(s - 1, s - 1);
    setState('Pair removed');
  }

Typing fade( leaves the value fade() with the caret at 5, and pressing ) there moves the caret to 6 without adding a character. The quote branch behaves the same way, and Backspace inside an empty pair takes both halves out, so a{} with the caret in the middle collapses to a.

04Tab indent and outdent

Tab inserts two spaces and Shift+Tab takes them back, and with several lines held the whole block moves while the selection survives.

Tab indent and outdent
Open in a new tab

04 · Tab indent and outdent — Measured area 16.572% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

selectionEndsplittab-size

Shifting every held line at once

// Multi-line hold: every held line is indented or outdented together, and selectionStart/End are recomputed by the amount each moved
function indentBlock(dir) {
  var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
  var ls = v.lastIndexOf(NL, s - 1) + 1;
  var ne = v.indexOf(NL, e);
  if (ne === -1) ne = v.length;
  var lines = v.slice(ls, ne).split(NL);
  var firstShift = 0, lastShift = 0;
  var moved = lines.map(function (ln, i) {
    if (dir > 0) {
      if (i === 0) firstShift = INDENT.length;
      if (i === lines.length - 1) lastShift = INDENT.length;
      return INDENT + ln;
    }
    var lead = ln.match(/^ {1,2}/);
    var cut = lead ? lead[0].length : 0;
    if (i === 0) firstShift = -cut;
    if (i === lines.length - 1) lastShift = -cut;
    return ln.slice(cut);
  });
  ta.value = v.slice(0, ls) + moved.join(NL) + v.slice(ne);
  // Recompute the selection: the start follows the first line's shift, the end follows the last line's
  ta.setSelectionRange(Math.max(ls, s + firstShift), Math.max(ls + 1, e + lastShift));
}

Holding lines 3 to 5 of the config sample, characters 24 to 62, and pressing Tab turned the leading spaces into 0, 0, 2, 2, 2 and carried the selection to 26 to 64. Shift+Tab put both back exactly — 0, 0, 0, 0, 0 and 24 to 62 — and pressing it once more with nothing left to remove changed neither the value nor the selection.

05Canvas minimap

A narrow canvas on the right paints line lengths as pixel bars instead of glyphs, the cheapest way to see the shape of a file that no longer fits the box.

Canvas minimap
Open in a new tab

05 · Canvas minimap — Measured area 15.017% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillRectgetContextscrollHeight

Bars measured, not text drawn

  var pitch = h / LINES.length;
  var barH = Math.max(1.5, pitch - 1.5);
  LINES.forEach(function (ln, i) {
    var bw = Math.max(2, (ln.trim().length / maxLen) * (w - 6));
    ctx.fillStyle = ln.indexOf('//') === 0 ? 'rgba(190,183,173,.40)' : 'rgba(255,247,230,.78)';
    ctx.fillRect(3, i * pitch, bw, barH);
  });

Each bar is a trimmed line length scaled against the longest line, and the 30-line sample comes out as 2,452 painted pixels with no glyph rendering at all. Dragging the viewport box from top to bottom walks scrollTop from 0 to 376 in a 536-pixel document, and scrolling the text to 120 puts the box at translateY(35.7px) on the way back.

06Find and replace bar

Ctrl+F drops the bar, every match takes a yellow band, and the counter tells you which match you are on out of how many.

Find and replace bar
Open in a new tab

06 · Find and replace bar — Measured area 15.163% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

matchAllmarkscrollIntoView

Every match in one pass

// matchAll pulls every position in one pass, keeping start and end indexes so the current one can be counted
function hits() {
  if (!q.value) return [];
  var re = new RegExp(escRe(q.value), 'g');
  return Array.from(ta.value.matchAll(re)).map(function (m) {
    return { s: m.index, e: m.index + m[0].length };
  });
}

Searching 4px in the token sample bands six spots with the counter at 1/6, and Enter walks it to 2/6 without touching the text. Replacing once rewrites only the current spot, so the six hits drop to five and the counter re-reads 2/5 from the fresh list rather than from a remembered number.

07Caret autocomplete popup

The list narrows to the word you are typing, and the popup opens directly under the caret, which is the part people miss most in a query box with a fixed keyword set.

Caret autocomplete popup
Open in a new tab

07 · Caret autocomplete popup — Measured area 8.792% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="listbox"aria-activedescendantstartsWith

Caret x measured by a mirror element

// Mirror element — measure the text before the caret in the same font to get the caret's x coordinate
function caretX(pos) {
  mirror.textContent = ta.value.slice(0, pos);
  var pad = parseFloat(getComputedStyle(ta).paddingLeft) || 0;
  return pad + mirror.offsetWidth - ta.scrollLeft;
}
// Align the popup's left edge with the caret x — fold it back inside if it runs past the right edge
function place(pos) {
  var x = caretX(pos);
  var maxX = ta.clientWidth - pop.offsetWidth - 4;
  pop.style.left = Math.max(0, Math.min(x, Math.max(0, maxX))) + 'px';
}

A hidden span in the same font holds everything before the caret, and the popup borrows its width, so after document.quer the popup stood at x 155 instead of the input's left edge at 54. The candidate list narrowed from 12 keywords to 2, one press of the down arrow moved aria-activedescendant from ac-opt0 to ac-opt1, and Enter swapped the half-typed fragment for document.querySelectorAll with the caret landing at 25.

08Squiggly error underline

An unclosed bracket or a malformed hex color gets a red squiggly underline and a tooltip that says why, which turns a paste box into a first line of defense.

Squiggly error underline
Open in a new tab

08 · Squiggly error underline — Measured area 32.71% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

text-decoration-styletext-decoration-skip-inkaria-describedby

A lint with exactly two rules

// A tiny lint — two rules only. (1) Hex color: the text after # must be hexadecimal and 3, 4, 6 or 8 digits long
// (a non-hex character stuck after # is caught as part of the same error). (2) Bracket pairs: match them on a stack
// and report every opening bracket left unclosed. Results come back in ascending character index.
function lint(text) {
  var errs = [], m;
  var hexRe = /#[0-9a-zA-Z]+/g;
  while ((m = hexRe.exec(text)) !== null) {
    var body = m[0].slice(1);
    var ok = /^[0-9a-fA-F]+$/.test(body) && HEX_DIGITS.indexOf(body.length) >= 0;
    if (!ok) errs.push({ start: m.index, end: m.index + m[0].length, msg: MSG_HEX });
  }
  var stack = [];
  for (var i = 0; i < text.length; i++) {
    var ch = text.charAt(i);
    if (ch === '{' || ch === '(' || ch === '[') stack.push(i);
    else if (ch === '}' || ch === ')' || ch === ']') {
      if (stack.length) stack.pop();
      else errs.push({ start: i, end: i + 1, msg: MSG_BRACKET });
    }
  }
  stack.forEach(function (p) { errs.push({ start: p, end: p + 1, msg: MSG_BRACKET }); });
  errs.sort(function (a, b) { return a.start - b.start; });
  return errs;
}

Clean input reports 0 errors, a five-digit #ff412 reports 1 with a tooltip naming the digit count, and a stray { reports 1 for the unclosed pair. The underline is two CSS words, underline wavy, and the same count is written as text and tied to the input with aria-describedby so it is not carried by color alone.

09Editor theme switcher

Light, dark, and high-contrast swap one bundle of CSS variables, so the token colors change together with the chrome instead of lagging a step behind it.

Editor theme switcher
Open in a new tab

09 · Editor theme switcher — Measured area 61.065% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

data-theme--tok-keycolor-scheme

Registering the color variables

// Register the color variables — only registered <color> variables cut cleanly with steps(1, end) in keyframes.
// The initial values are the light set (the static default state)
@property --ed-bg  { syntax: '<color>'; inherits: true; initial-value: #{$stage-paper}; }
@property --ed-fg  { syntax: '<color>'; inherits: true; initial-value: #{$subject-ink}; }
@property --tok-key { syntax: '<color>'; inherits: true; initial-value: #{$subject-blue-deep}; }
@property --tok-str { syntax: '<color>'; inherits: true; initial-value: #7440d4; }
@property --tok-num { syntax: '<color>'; inherits: true; initial-value: #{$error}; }
@property --tok-com { syntax: '<color>'; inherits: true; initial-value: #6b6577; }

All the script does is set data-theme to hc, and the selector swaps six colors plus color-scheme behind it. Measured per text color against the black panel, the high-contrast set reads 21.0:1 for body text, 14.54:1 for keywords, 11.88:1 for strings, 11.37:1 for numbers, and 12.92:1 for comments.

How far does a code editor ui design go without a library?

This is roughly the ceiling. The moment you need several open files, an undo history you control, or a language server answering back, a real document model has to exist, and a purpose-built editor is the honest answer. Below that line — one box that colors, numbers, closes, finds, completes, and complains — two elements carry all of it, and all nine sit on <textarea> plus <pre> with no dependency to install.

Each part is also a standalone code editor ui component, so the gutter can go into a config field on its own and the lint underline can go into a hex input without dragging the other eight along. For a read-only snippet, the frame around the code is a different job, covered in 9 Code Block UI Patterns, and a command-line window is closer to Terminal UI Design. The behavior of the input element itself is documented in MDN's textarea page and setSelectionRange.

Where it breaks — the trap

What the grid plays is a CSS keyframe loop; what your hands do is the JS path. They are separate code paths, so one of them can be dead while the measurement still passes.

Sync code that was never running

02 carried a function that moves the gutter by the text's scrollTop, and for a while it did nothing at all: the textarea was free to grow with its content, so the element that actually scrolled was the box around it. A screen that computes its own coordinates, like the minimap in 05, shares this trap with CSS Chart Design: 9 SVG Graphs, No Library — the drawing is right and the thing being measured is wrong. The password for the nine zip files is 8j2t7h4k, and for exactly this reason, every part was measured twice — once on the static path with .is-demo removed, once through real key events. With the textarea pinned to the height of its grid cell, a 12-line file now measures scrollHeight 256 against clientHeight 116, and scrolling the text to 40 puts both the gutter track and the overlay at matrix(1, 0, 0, 1, 0, -40).

A one-step cut only changes at the end of the step

01 first wrote 0% { opacity: 0 } 100% { opacity: 1 } under steps(1, end). A cut with a single step changes its value at the end of that step, so the layer stayed transparent for the full two seconds and the poster came out as an empty screen: 0.26% moved area and 4/23 frames with motion. Putting a separate jump at 3% took the same clip to 25.07% and 15/23, and 06 had the same shape, which is why its match bands start already lit and the motion is the walk between matches.

Status text written only by JavaScript

04, 05, and 09 write their status line with textContent, and inside the preview loop that code never runs, so whatever the markup shipped with stays on screen. The poster for 04 said the caret was on line 1 while the picture showed lines 3 to 5 held. The fix was two stacked sentences that CSS picks between, and 05 moves continuously, so its preview sentence drops the number entirely.

Three variants and accessibility

Three variables change the character of all nine at once.

name changed value feel
duration $dur-loop 2s → 1s typing and popups run twice as fast
easing $ease-spring → $ease-pop the popup and the bar bounce one more time
color $subject-blue → $stage-yellow token accents move from the cold side to the warm

Every input here is a real <textarea>, so screen readers, IME composition, and native undo keep working without a single patch. The popup in 07 announces itself through role="listbox" and aria-activedescendant while focus stays in the input, and 08 keeps its error count as text rather than as color alone. Under prefers-reduced-motion: reduce, all nine stop the preview loop and the caret blink, and leave the one still frame that reads best for that part. Contrast was measured from rendered pixels across 60 text samples in the nine screens with zero below AA, and the high-contrast theme in 09 was measured separately on its black panel.

FAQ

Is there a code editor ui react version in the files

Yes — the zip ships a react/ folder next to vanilla/, with a .tsx component and a CSS module for each of the nine. The DOM and the event handlers are the same ones shown above, moved into refs and state.

Will Korean or Japanese input break while composing

No, because the real input stays a <textarea> and composition is handled by the browser. A contenteditable rewrite is where composition usually breaks, since the DOM is edited mid-composition; here only the overlay is redrawn, and the input path is never touched. When the code window only has to be read and never edited, 9 Code Block UI Patterns is the lighter answer.

Does the squiggly underline show up in older browsers

caniuse puts text-decoration-style at 96.65% global support (0.03% full plus 96.62% partial), covering Chrome 57+, Edge 79+, Safari 7.1+, and Firefox 6+, checked on 2026-09-13. Internet Explorer and Opera Mini have none of it, which is the other reason the error count is also written out as text.

Enter the archive password

The password is inside this article. You will find it as you read.