TermDOM: Build Terminal UIs with HTML, CSS and the DOM
Technical Deep-Dive · August 2026

TermDOM: Build Terminal UIs
with HTML, CSS and the DOM

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.

Oleg Maximov August 18, 2026 15 min read

Introduction

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.

The Idea: A Real DOM in Your Terminal

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.

The core promise

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.

How the Rendering Pipeline Works

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:

  1. Style. CSS from <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.
  2. Layout. The box model, flexbox and table layout compute boxes in whole terminal cells, with margins, borders and padding.
  3. Paint. The engine paints the result into a cell buffer.
  4. Diff. The new buffer is diffed against the previous frame, and only the difference is written to stdout as ANSI escape sequences.
  5. Input. Input runs the other way: escape sequences from stdin are decoded into keyboard, mouse and paste events, then dispatched to DOM nodes.

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.

Getting Started: The Install Bar Demo

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.

Frameworks

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.

Building a Small TUI: An Interactive Task List

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.

Feature Tour

Scrolling

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: Keyboard, Mouse, Focus, Paste

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.

Forms: input, textarea, select, checkbox

<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);

Web Components

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>
`;

Text, Selection and Fullscreen

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.

TermDOM vs Ink vs blessed vs clack

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.

Limitations and Early-Stage Caveats

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:

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.

FAQ

What is TermDOM?
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 framework. Install it with npm install @b9g/termdom, create a TermDOM instance, attach it, and the document is a real DOM document.
Does TermDOM replace Ink or blessed?
It offers a different paradigm. Ink is a React-based library that renders React components to terminal strings, blessed is a classic widget toolkit, and clack is a prompts library. TermDOM gives you a real DOM document with a real CSS cascade — you write a web page and get a TUI. Choose it when web-tech familiarity, CSS layout and web component reuse matter more than widget-specific APIs.
Can I use React or Vue with TermDOM?
Yes. TermDOM works with vanilla JavaScript or any frontend framework. React mounts with document and window alone; Vue also reads the DOM constructors for instanceof checks and captures the document at module load, so framework globals must be set up before a dynamic import brings it in. The TodoMVC example runs the official component logic unmodified — only the stylesheet was swapped.
Which runtimes does TermDOM support?
TermDOM runs on Node.js, Bun and Deno. The library has no native components, so it can also be compiled into standalone binaries with tools like bun build --compile. Examples in the repository run on all three runtimes and import the package by name.
Is TermDOM production-ready?
The project is young and released as a pre-release (0.1.3 at the time of writing). The compatibility matrix on termdom.org reports 103 features supported, 108 probed and unsupported, and 301 CSS properties not yet probed. It is excellent for experiments and internal tools, but evaluate carefully before building a critical product on it — the API is still stabilizing.
What CSS does TermDOM support?
The box model, flexbox, table layout, colors, background, borders, text decoration and alignment work in whole terminal cells. CSS grid, transitions, animations, opacity, transform and most logical properties are not supported. Physical properties (border-top, margin-left) work; logical ones (border-block, margin-inline) are probed and have no effect.
What is the difference between TermDOM and DomTerm?
The two projects are each other's inverse. DomTerm is Per Bothner's terminal emulator built out of DOM elements — it puts a terminal in the DOM. TermDOM puts the DOM in a terminal: it renders DOM nodes to the terminal screen. The names are confusingly similar, so it is worth keeping the direction straight.

Want to Build a Terminal or Web App?

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.

Contact

Let's build something fast

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.