What if your terminal were a browser? TermDOM renders a real DOM document — with a real CSS cascade, flexbox, forms and web components — to the terminal screen. This deep-dive shows how it works and what you can build with it today.
For years, building a terminal user interface meant learning a new world: widget trees, layout calls, key-binding tables. Ink brought React components to the terminal. blessed gave you a classic widget toolkit. But every approach made you think in terminal-specific abstractions instead of the web platform you already know.
TermDOM takes the opposite route: it implements the browser's rendering
pipeline against a grid of character cells instead of pixels. The result is a library that
renders a real DOM document — the kind you manipulate with document.querySelector()
and innerHTML — straight to your terminal. Node Weekly issue 637 (August 13, 2026)
featured it as the headline article, and the repository was actively pushed as recently as
August 16, 2026, so this is a fresh look at a moving target.
In this article I'll walk through what TermDOM actually is, how its rendering pipeline works, how to build a small TUI step by step, where it fits next to Ink, blessed and clack, and what the compatibility matrix says about its limits today.
TermDOM is a JavaScript library that displays HTML and CSS in the terminal. It draws actual DOM nodes to terminal output and redraws the screen when they mutate, so terminal UIs and interactive CLIs can be written with vanilla JavaScript or any frontend web framework. The README puts it in one line: "Write a web page. Get a TUI."
The npm package is @b9g/termdom (version 0.1.3 at the time of writing, published
as a pre-release):
npm install @b9g/termdom
The minimal program looks like this:
import {TermDOM} from "@b9g/termdom";
const term = new TermDOM();
term.attach();
// The document is a real DOM document.
const {document} = term;
document.body.innerHTML = `
<style>
.card { border: 1px solid #5fafff; padding: 0 1ch; width: 36ch; }
.title { color: #5fafff; font-weight: bold; }
</style>
<div class="card">
<div class="title">Hello, terminal</div>
<div id="status">Ready</div>
</div>
`;
That's it. The library takes over the terminal, parses the markup, applies the styles and paints the result. Every glyph on screen is a DOM element; every change to the DOM is reflected automatically. There is no render call, no virtual DOM diff you write yourself — mutations paint, like in a browser.
One cell is 1ch wide and 1px tall. Every box lands on whole cells.
You write a web page, TermDOM renders it as a TUI.
TermDOM implements the browser's rendering pipeline against a grid of character cells instead
of pixels. CSS lengths map onto the grid — 1px is one row, 1ch is
one column — so the box model, flexbox and tables lay out in whole cells. Here is the full
cycle as the README describes it:
<style> elements and style attributes cascades and inherits like it does in the browser, then is translated to ANSI escapes for color and text decoration.
The mutation-driven redraw is what makes it feel like the browser. A MutationObserver
watches the document; when something changes, the engine recomputes style and layout for
whatever mutated, repaints and diffs. The DOM utilities you already know are hooked up to the
layout engine and viewport: document.querySelector(),
MutationObserver, ResizeObserver, and
Element.getBoundingClientRect() all follow browser standards.
A simplified mental model of the frame loop:
while (running) {
// 1. Any DOM mutations? MutationObserver callbacks queued them.
const changes = drainMutationQueue();
if (changes.length > 0) {
// 2. Recompute style for affected subtrees (cascade + inheritance).
for (const node of changes) {
recomputeStyle(node); // CSS -> resolved colors, decorations
layout(node); // box model, flexbox, tables -> whole cells
}
// 3. Paint the cell buffer, diff against the last frame.
const buffer = paint(document.body);
const patch = diff(previousBuffer, buffer);
// 4. Write only the difference to stdout as ANSI sequences.
if (patch.length > 0) writeToStdout(patch);
previousBuffer = buffer;
}
}
Because output is a diff, a spinner that changes one <span> per frame
costs a few escape sequences, not a full redraw. And because input is decoded from stdin,
your keydown listeners receive ordinary DOM events.
The canonical first example is an "installing" progress card. It uses a
setInterval to mutate three spans, and TermDOM paints each mutation:
import {TermDOM} from "@b9g/termdom";
const term = new TermDOM();
term.attach();
const {document} = term;
document.body.innerHTML = `
<style>
.card { border: 1px solid #5fafff; padding: 0 1ch; width: 36ch; }
.title { color: #5fafff; font-weight: bold; }
.done { color: green; }
.rest { color: #444; }
.pct { color: #888; }
</style>
<div class="card">
<div class="title">Installing</div>
<div>
<span class="done" id="done"></span><span class="rest" id="rest"></span>
<span class="pct" id="pct"></span>
</div>
</div>
`;
// TermDOM observes mutations and re-renders automatically.
let n = 0;
setInterval(() => {
n = (n + 1) % 101;
const cells = Math.round(n / 4);
document.getElementById("done").textContent = "█".repeat(cells);
document.getElementById("rest").textContent = "░".repeat(25 - cells);
document.getElementById("pct").textContent = String(n).padStart(3) + "%";
}, 50);
The bar is drawn with block characters, the percentage is right-aligned with
padStart, and the colors come from the CSS cascade. Notice what is missing:
no term.render(), no manual cursor positioning, no ANSI string concatenation.
The DOM update is the render.
Because the document and events follow browser standards, frontend frameworks work almost
out of the box. The getting-started guide verifies the globals each framework expects
against React 19 and Vue 3.5: React mounts with document and
window alone, while Vue reads the DOM constructors for
instanceof checks and captures the document at module load — so you install
the framework globals before a dynamic import brings it in. The same guide also shows that
the library runs on Node, Bun and Deno alike, since the examples import
@b9g/termdom by package name.
The project's flagship example proves how far the web-platform approach goes: the official TodoMVC runs with its component logic unmodified — only the stylesheet was swapped. The same markup, the same JavaScript, a terminal-friendly stylesheet, and it becomes a TUI. That is the strongest argument for TermDOM's design: existing web code can be retargeted at the terminal.
Here is a minimal interactive list with keyboard navigation, using only vanilla DOM APIs:
const {document} = term;
document.body.innerHTML = `
<style>
.list { border: 1px solid #888; width: 40ch; padding: 1ch; }
.item { padding: 0 1ch; }
.item.selected { background-color: #5fafff; color: #000; }
</style>
<div class="list" id="list"></div>
`;
const items = ["Build a TUI", "Ship it", "Celebrate"];
const list = document.getElementById("list");
let selected = 0;
function render() {
list.innerHTML = "";
items.forEach((text, i) => {
const div = document.createElement("div");
div.className = "item" + (i === selected ? " selected" : "");
div.textContent = text;
list.appendChild(div);
});
}
document.addEventListener("keydown", (ev) => {
if (ev.key === "j") selected = (selected + 1) % items.length;
if (ev.key === "k") selected = (selected - 1 + items.length) % items.length;
render();
});
render();
Keyboard events arrive as real DOM events on document, classes are toggled with
ordinary strings, and the selected row gets a background color from CSS. The same code runs
in a browser with a different stylesheet — that is the point.
The repo's examples directory shows the range: a Markdown viewer that pages when the document is taller than the terminal, a streaming LLM chat client with a transcript and composer, a fuzzy file finder, an emoji weather forecast with flexbox day cards, a menu bar built from declarative popovers, and a fully playable Klondike solitaire with seeded deals.
Documents taller than the terminal scroll with window.scrollTo() and
element.scrollIntoView(). In the NERDTree-style file browser example, moving the
selection calls scrollIntoView() to move the "camera":
document.addEventListener("keydown", (ev) => {
if (ev.key === "j") select(selected + 1);
if (ev.key === "Enter") expand(rows()[selected]);
});
// Keep the selected row visible.
rows()[selected].scrollIntoView();
Events for keys, mouse, focus and paste fire on elements, the document and the window, pulled
from STDIN. This is a large part of why frameworks work: they attach listeners to
document and window exactly as they would in a browser, and TermDOM
supplies the events. Mouse support enables drag-selection and clickable rows; paste lets a
chat composer accept clipboard input.
<input>, <textarea>, <select>,
checkboxes and radios come with default behavior and terminal-native looks, and can be
restyled with ordinary CSS. Tab navigation and :focus styles work. The caret is
the real terminal cursor, so CJK input methods compose inside the field, measured in cells:
document.body.innerHTML = `
<style>
.field { margin-bottom: 1ch; }
.label { color: #5fafff; }
input { border: 1px solid #888; }
input:focus { border-color: #5fafff; }
</style>
<div class="field">
<div class="label">Name</div><input id="name">
</div>
<div class="field">
<div class="label">Priority</div>
<select id="priority">
<option>low</option><option>high</option>
</select>
</div>
`;
document.getElementById("name").addEventListener("input", updatePreview);
customElements.define(), attachShadow(), <slot>,
:host and scoped styles behave like the browser's. The built-in form controls are
themselves shadow trees. This means component libraries written for the web — not for the
terminal — can render inside TermDOM:
class StatusBadge extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({mode: "open"});
shadow.innerHTML = `
<style>
:host { border: 1px solid #5fafff; padding: 0 1ch; }
.ok { color: green; }
</style>
<span class="ok"><slot></slot></span>
`;
}
}
customElements.define("status-badge", StatusBadge);
document.body.innerHTML = `
<status-badge>deployed</status-badge>
`;
CJK, emoji and combining characters take their correct widths; Hebrew and Arabic render in
visual order with contextual shaping, and the caret moves by grapheme. You can drag to select
text and style the selection with ::selection. Element.requestFullscreen()
renders an element to the alternate screen, and exiting restores the shell and its scrollback —
the classic fullscreen-app behavior.
How do you choose? The table compares the four approaches most people reach for:
| TermDOM | Ink | blessed | clack | |
|---|---|---|---|---|
| Model | Real DOM + CSS | React components | Widget tree | Prompts |
| Layout | Box model, flexbox, tables | Yoga (flexbox) | Manual absolute/grid | Predefined |
| Styling | CSS cascade + inheritance | Inline styles in JSX | Per-widget style objects | Theme presets |
| Framework | Any (vanilla, React, Vue…) | React only | None | None |
| Web Components | Yes (shadow DOM) | No | No | No |
| Reuse web code | Yes — swap stylesheet | React components | No | No |
| Maturity | Young, pre-release | Mature | Mature (maintenance) | Mature |
Choose TermDOM when you want to reuse web skills and web components, or when your team already thinks in HTML and CSS. Choose Ink when you live in the React ecosystem and want a battle-tested library with a component model. Choose blessed for classic widget-based terminal apps that need fine-grained control and don't mind a lower-level API. Choose clack when all you need is a set of polished prompts for a CLI.
TermDOM is young. The npm package is a pre-release (0.1.3), the repository had about 133 stars at the time of writing, and the API can still shift. The project is honest about this: the compatibility matrix is generated by a probe suite that applies each DOM API, selector and CSS property to a real document and records whether the output changed. The current numbers: 103 features supported, 108 probed and unsupported, 301 CSS properties not yet probed.
What that means in practice:
grid-template-columns and friends are probed and unsupported. Flexbox and tables are the layout tools.border-block, margin-inline, inset-block and friends are probed but have no effect; use the physical properties instead.box-sizing, float or clear — the layout engine follows the block/flex/table model only.font-family, font-size and line-height have no effect; a terminal has one font, and text is measured in cells.The README also documents the "What is not supported" section explicitly, so you meet the limits before you meet a bug. For experiments, internal developer tools and anything where a browser-grade DOM is worth more than widget polish, this trade-off is very attractive.
If you are comparing this to other ways of rendering HTML outside a browser window, my HTML-in-Canvas guide covers the opposite direction: painting real DOM elements onto a canvas with pixels instead of cells.
Terminal UIs built on web standards are a genuinely fresh direction, and TermDOM is the most literal example yet: write HTML and CSS, get a TUI. As a full-stack developer, I build command-line tools and web applications alike, and I always pick the rendering model that makes the product simplest to maintain.
If you have a product idea — terminal-based or browser-based — and want an experienced perspective on the right architecture, reach out. I provide free initial consultations, no pressure, no sales pitch.
Have a project in mind? I'll help you choose the right architecture — web app, CLI tool, or something in between — and build it right. Free initial consultation.