termdom-vue

Vue 3 renderer for TermDOM: Vue components rendered to the terminal through a real DOM.
368 2
Install
npm install termdom-vue
Latest Version:0.1.0
Vue:3
License:MIT
Last Updated:Aug 13, 2026
Links: GitHub  ·  npm
Maintainer: defenderbass

termdom-vue

Vue 3 in the terminal, by way of TermDOM.

There is no custom renderer here and no component vocabulary to learn. TermDOM implements a real DOM with real CSS, so @vue/runtime-dom renders into it the way it renders into a browser's — templates, v-if/v-for, v-model, refs, template refs, transitions. What this package adds is the wiring: globals installed in the right order, composables for what a terminal has and a browser does not, and one specific error message that saves an afternoon.

npm install termdom-vue vue
// main.ts
import {render} from "termdom-vue";

export const css = `.card {border: 1px solid #5fafff; padding: 0 1ch;}`;

await render(() => import("./app.js").then((module) => module.App), {css});
// app.ts
import {defineComponent} from "vue";
import {useExit, useKeydown} from "termdom-vue";

export const App = defineComponent({
	setup() {
		const exit = useExit();
		useKeydown((event) => event.key === "q" && exit());
	},
	template: `<div class="card">hello — press q to quit</div>`,
});

Vue is a peer dependency — and note that the package never imports it until you mount, for the reason below.

Running the examples

The examples live in the repository, not in the package — clone it, then:

npm install
npm run hello       # a card, a stylesheet, one global key
npm run counter     # refs, keyboard input, a bar drawn with flexbox
npm run todo        # v-model, checkboxes, list navigation, focus
npm run dashboard   # full screen, live data, a media query that reflows
npm test            # the suite, including the examples, headless

Node 24 runs these directly; the examples are TypeScript and go through tsx. Templates are compiled at runtime — Node resolves vue to a build that carries the compiler, so there is no build step.

The import-order rule

This is the one thing about Vue in a terminal that is not like Vue in a browser, and it is worth understanding rather than pattern-matching.

@vue/runtime-dom reads the document once, when its module evaluates:

const doc = typeof document !== "undefined" ? document : null;

Every node the renderer ever creates comes from that captured reference. A Node process has no document until render() builds a terminal and installs one — so if Vue is imported before that, doc is null for the rest of the process and mounting dies on Cannot read properties of null (reading 'createElement'). Installing the globals afterwards changes nothing; the capture already happened.

So nothing may import Vue until render() has run, which has two consequences:

  1. This package never imports Vue statically. src/vue.ts holds a lazily loaded copy; the composables reach for it, by which time they are running inside a mounted component. If you extend this package, keep it that way — one import {ref} from "vue" under src/ re-breaks everything.
  2. Your components are handed to render() as a loader, not as a value: render(() => import("./app.js").then((m) => m.App)). The entry point holds the stylesheet and the loader — neither needs Vue — and the component module, which does, is imported after the terminal exists. That is why each example here is a pair: 03-todo.ts (entry) and 03-todo.app.ts (components).

Get it wrong and render() says so, in those words, with the original TypeError as cause. There is a test that imports Vue too early on purpose to keep that message honest.

API

render(source, options?): Promise<TermVueApp>

Mounts a Vue application into a terminal and resolves once it is on screen. source is a component or, preferably, a loader returning one ({default} interop included).

option default what it does
transport the process's terminal Where to render. Tests and embeds pass their own; see Testing.
globals "vue" "vue" publishes document, window, Element and SVGElement; "dom" adds the constructors and helpers third-party libraries reach for; "none" publishes nothing.
fullscreen false Take the alternate screen, so the shell's scrollback comes back untouched on exit.
css — A stylesheet appended to <head> before the first render.
props — Props for the root component.
setup — Called with the App before mounting: (app) => app.use(pinia).

The returned object carries the terminal (term, document, window, columns, rows, exit()), the Vue app, the host element, a closed promise, and two teardowns: unmount() (Vue goes, terminal stays) and dispose() (both go, process stays alive).

Vue needs exactly four globals. document and window are the obvious two; Element and SVGElement are there because mount() tests its container against both — on every mount, whether or not an SVG is anywhere in sight — and a missing constructor is a ReferenceError before a single node is made.

Composables

  • useTerminal() — the terminal for this tree: term, document, window, columns, rows, exit(). Throws outside a render() tree rather than silently reaching for a global.
  • useTerminalSize() — {columns, rows} as refs that follow every resize. Layout usually belongs in CSS — TermDOM runs @media queries against the real terminal — so reach for this when the decision is not expressible as style.
  • useKeydown(handler, options?) — a document-level keydown listener, unwound with the component's scope. @keydown in a template only fires for the element it is on; a global shortcut has to reach the document. Options: target, capture.
  • useExit() — closes the window: last frame flushed, terminal restored, process exited. The same thing an unhandled Ctrl-C does.

How it works

Globals go up before Vue loads — the rule above, implemented in render().

Resizes come from a probe element (src/size.ts). TermDOM has no window.onresize: SIGWINCH lands as a media-query re-evaluation and a fresh layout. A ResizeObserver on <html> measures content height, so a terminal that gets shorter without getting narrower looks unchanged; matchMedia only fires when an answer flips, and re-arming a query per resize leaks. So one hidden 100vw × 100vh element, pinned with position: fixed, gets a single ResizeObserver, and its callback writes the two refs.

A second terminal in the same process works, despite Vue creating its nodes with the first terminal's document: inserting a node into another document adopts it, so layout and media queries follow the tree a node is in. The suite asserts this rather than assuming it.

Writing terminal CSS

The layout engine is real CSS, but the canvas is a character grid. These hold for TermDOM 0.1.1 and were all found by rendering the examples:

  • Lengths need units. A column is 1ch, a row is 1px. padding: 1 2 is silently dropped; padding: 1px 2ch is one row of vertical padding and two columns of horizontal.
  • Percentage widths resolve against the viewport, not the containing block: width: 50% inside a 20ch box comes out as half the terminal. Build proportional bars out of flex children that grow against each other (:style="{flexGrow: n}"), which is what the counter and dashboard examples do.
  • flex: 1 sizes items by their content and lets them overflow a narrow terminal. flex: 1 1 0px with min-width: 0 splits a row evenly — note the unit on the basis.
  • There is no implicit form submission. @submit will not fire when Enter is pressed in a field; handle Enter in @keydown.
  • Whitespace in a template is text on screen. An interpolation on its own line becomes a leading space in a cell grid; keep {{…}} on the element's line.

Forms, and where batching bites

v-model works — typing updates the ref with no shim of any kind, which is the whole difference from React, where a value tracker has to be defeated before onChange fires at all.

The catch is batching. A terminal delivers input in chunks, and everything in one chunk runs before Vue's next render: paste ship it\n into a field and the text and the Enter are processed back to back. A handler that reads the model and resets it in that same burst leaves v-model comparing the value it last wrote ("") with the value now (""), concluding there is nothing to write — while the field still holds what was typed. The todo example resets the element alongside the ref, with a comment; the same is true of a plain :value binding, and the events suite covers both.

Testing

test/harness.ts puts a headless xterm behind TermDOM's transport interface, so a test drives the app the way a user would and reads back what is on screen.

const terminal = new TestTerminal({cols: 40, rows: 10});
const app = await render(loadCounter, {transport: terminal});

terminal.type("+++");         // keys in, escape sequences literal
await terminal.settle();      // wait for Vue, TermDOM, and the emulator
assert.match(terminal.screen, /n=3/);

await app.dispose();

terminal.screen and terminal.lines are the emulator's cell grid as text; terminal.cell(x, y) reports the resolved style of one cell. settle() waits for the terminal to go quiet rather than counting ticks — a resize is coalesced for tens of milliseconds before anything repaints.

26 tests: the renderer and the composables, the event surface most at risk (v-model on text/checkbox/select, listener teardown, Ctrl-C), the four examples, and the import-order diagnostic. Note that no test file imports vue at the top — except the one whose whole point is to.

Known limits

  • Single-file components are untried here. They need a build step (@vue/compiler-sfc through a bundler), which is orthogonal to the terminal but would change how examples are laid out; runtime template compilation was the simpler demonstration.
  • <Transition> and <TransitionGroup> are untried. They lean on getComputedStyle transition timings, which a character grid has no equivalent for.
  • Vue Router and Teleport are untried. Teleport to a selector inside the same document ought to work; the router wants a URL the terminal does not have.
  • app.closed resolves on the decision to close, not on the last byte; await the transport's own closed for that.
  • unmount() leaves the globals up for one turn, because Vue flushes teardown work on a microtask. dispose() waits that turn out for you.

Related Packages

@vue-tui/runtime

Vue 3 terminal renderer with Yoga flexbox layout, inspired by React Ink.

3,402 366
vue-termui

Build terminal apps with Vue 3

24,392
@vizejs/fresco

Vue TUI framework - Build terminal UIs with Vue.js

71,222 908
lattice-tui

A keyboard-first, Obsidian-inspired Markdown knowledge base for the terminal.

1,197 0
@vizejs/fresco-native

Vue TUI framework - Native bindings

76,634 908

Version History

Version Released Vue License
0.1.0 ^3.5.0 MIT