How it works
svelterm is a Svelte custom renderer plus a CSS engine plus a terminal painter. There is no DOM anywhere — not emulated, not shimmed. This page walks the path from your component source to ANSI bytes on a terminal.
No DOM
Svelte’s compiler doesn’t hardcode the DOM: compiled components call a
renderer interface — create an element, insert a node, set an attribute,
set text, listen for an event. In a browser that interface is backed by document. svelterm (via the experimental custom renderer API)
supplies a different backing: every call builds and mutates a tree of TermNode objects — plain values holding a tag, attributes, children,
event listeners, and a little render cache. No document, no window, no
JSDOM.
Everything Svelte does — reactivity, $state, effects, {#if}/{#each} patching, component composition, scoped styles — works unchanged, because
Svelte only ever asks the renderer to make small tree mutations. Your
component doesn’t know it’s in a terminal; a handful of DOM-compat
properties on TermNode (textContent, nodeValue, checked, value)
cover the places Svelte’s generated code reads nodes directly.
The renderer itself is stateless; each node carries its render context
(node.ctx), attached on insert and cleared on remove, so trees can be
built detached and adopted later.
The pipeline
Each frame runs a browser-shaped pipeline against the TermNode tree,
with one radical simplification: the unit of geometry is a character
cell, not a pixel.
component source
│ svelte compiler (customRenderer)
▼
TermNode tree ◄── Svelte reactivity mutates it
│ resolve styles cascade, specificity, selectors, var(), @media
▼
ResolvedStyle per node
│ layout block/inline flow, flex, grid, tables → cells
▼
LayoutBox per node { x, y, width, height } in cells
│ paint borders, text, backgrounds, form glyphs
▼
CellBuffer every cell: { char, fg, bg, bold, … }
│ diff vs previous buffer
▼
minimal ANSI cursor moves + SGR codes, sync-wrapped - Style resolution matches the stylesheet’s selectors against the
tree and folds the cascade into one
ResolvedStylestruct per element — the same job as a browser’s computed style, minus pixel-derived properties (which parse and drop; see compatibility). - Layout implements block/inline flow, flexbox, grid, and table algorithms over integer cells. Every length rounds to whole cells; borders are one cell thick.
- Paint writes glyphs and colours into a
CellBuffer— including box-drawing borders, list markers, form-control glyphs like[x], and::before/::afterruns. - Diff compares against the previous buffer and emits only the changed cells as ANSI (cursor positioning + colour codes), wrapped in a synchronized-update sequence so the terminal repaints atomically.
Incremental updates
Re-running the whole pipeline per keystroke would waste most of its work, so mutations are classified at the point Svelte makes them:
- same-length text change → paint only
- attribute change → re-resolve styles for the node and its descendants (any attribute can affect selector matching)
- size-affecting change → layout, scoped to a subtree or bubbled to the nearest fixed-size ancestor
A microtask-batched queue coalesces a burst of mutations into one render, which runs only the stages the batch needs — an incremental style pass, an incremental layout pass, and a repaint clipped to the damaged region.
Input, without a browser
Raw stdin bytes are parsed into key events (including modifiers and
escape sequences), SGR mouse events with cell coordinates, and
bracketed paste. Mouse positions hit-test against the layout boxes to
find the target node; events then dispatch through the tree with
W3C capture/bubble semantics, preventDefault() and all. Focus is a
document-order traversal of focusable elements driven by Tab, with :focus/:hover implemented as attribute-backed pseudo-classes.
Default actions (link opening, checkbox toggling, select cycling,
label activation) live in the run loop, mirroring browser behaviour.
Time
An animation clock discovers elements whose resolved style declares an animation or whose transition targets changed, applies the current interpolated values onto their resolved styles (~30fps), and enqueues paint-only or layout invalidation per frame depending on what the animation touches — see motion.
The terminal is an interface too
All output goes through a small TerminalIO interface rather than process.stdout directly. ProcessIO backs real terminals (raw mode,
alternate screen, resize signals, OSC 11 colour-scheme queries); InProcessIO backs anything that can accept a byte stream — which is
how the playground works: the same engine, compiled for the browser,
writes its ANSI into an xterm.js instance. The browser pane next to it
is simply the same component compiled with Svelte’s normal DOM renderer.
One source, two render targets — that contrast is the whole point.