wterm: A Terminal Emulator for the Web Built on Zig and WASM

April 18, 2026

|repo-review

by Florian Narr

wterm: A Terminal Emulator for the Web Built on Zig and WASM

wterm is a terminal emulator that runs in the browser and renders to the DOM. You get native text selection, clipboard, Ctrl+F find, and screen readers for free — because it's just HTML, not a canvas.

Why I starred it

Most web terminals (xterm.js, being the obvious reference) render to canvas. That makes them fast but opaque — the browser doesn't know what text is on screen, which breaks selection and accessibility. wterm flips that: DOM rendering, real text nodes, CSS for styling. The tradeoff is historically performance — parsing and applying terminal escape sequences in JavaScript at full speed is slow.

wterm's answer is to push the VT parsing into Zig compiled to WASM. The WASM binary is ~12 KB (release build). The JavaScript layer never touches escape sequences — it only reads cell data out of WASM memory and turns it into DOM nodes.

How it works

The core is split across six Zig files in src/. The entry point for WASM is src/wasm_api.zig, which exports a flat C-ABI interface: init, resizeTerminal, writeBytes, getGridPtr, getDirtyPtr, and a handful of getter functions for cursor state, title, scrollback, and response buffers.

The terminal grid is a fixed-size 2D array of Cell structs, with a parallel dirty byte array tracking which rows changed:

// src/grid.zig
pub const Grid = struct {
    cells: [MAX_ROWS][MAX_COLS]Cell = undefined,
    cols: u16,
    rows: u16,
    dirty: [MAX_ROWS]u8 = [_]u8{1} ** MAX_ROWS,
    ...
    pub fn setCell(self: *Grid, row: u16, col: u16, cell: Cell) void {
        if (row >= self.rows or col >= self.cols) return;
        self.cells[row][col] = cell;
        self.dirty[row] = 1;
    }
};

Every cell write flips dirty[row] = 1. The JS renderer reads getDirtyPtr() before each animation frame and skips rows that haven't changed. This is a clean separation: the Zig side knows what changed, the JS side decides when to re-render. No VDOM, no diffing library.

The VT parser in src/parser.zig is a handwritten state machine with eight states (ground, utf8, escape, csi_param, osc_string, etc.). It handles UTF-8 decoding inline and produces typed Action values — print, execute, csi_dispatch, osc_dispatch. The Parser.feed() function takes one byte at a time and is called inside a tight loop in terminal.zig's write() method. No allocations in the hot path.

The TypeScript side in @wterm/core uses WasmBridge to talk to the WASM instance. It reads cells by direct DataView into the WASM linear memory:

// packages/@wterm/core/src/wasm-bridge.ts
getCell(row: number, col: number): CellData {
  const offset = this.gridPtr + (row * this.maxCols + col) * this.cellSize;
  const dv = this._dv;
  return {
    char: dv.getUint32(offset, true),
    fg: dv.getUint16(offset + 4, true),
    bg: dv.getUint16(offset + 6, true),
    flags: dv.getUint8(offset + 8),
  };
}

No serialization, no copying — it's reading directly from the WASM memory buffer. The cellSize constant (12 bytes) is even enforced at compile time in Zig with a comptime assertion that will error if the struct layout drifts.

The DOM renderer in packages/@wterm/dom/src/renderer.ts handles the color mapping. It converts the 256-color palette and 24-bit RGB to CSS via a colorToCSS() function, and handles the Unicode block element characters (U+2580–U+259F) by converting them to CSS linear-gradient backgrounds — which means things like progress bars render pixel-accurately without needing canvas.

The WASM binary can be loaded two ways: from a URL (you host wterm.wasm yourself), or inline — the build scripts base64-encode the binary into a wasm-inline.js file so the package is zero-dependency self-contained at the cost of slightly larger JS.

The React package (@wterm/react) exposes a <Terminal /> component built with React 19's callback ref pattern instead of useEffect for WASM init. This avoids the double-mount issue in strict mode and gives clean teardown.

Using it

The React path is three lines after install:

import { Terminal, useTerminal } from "@wterm/react";

export default function MyPage() {
  const term = useTerminal();

  return (
    <Terminal
      ref={term.ref}
      cols={80}
      rows={24}
      onData={(data)=> {
        // send to your PTY backend
        ws.send(data);
      }}
    />
  );
}

For a full PTY connection, the @wterm/core package includes WebSocketTransport with exponential backoff reconnection built in:

import { WebSocketTransport } from "@wterm/core";

const transport = new WebSocketTransport({
  url: "wss://your-server/pty",
  reconnect: true,
  maxReconnectDelay: 30000,
  onData: (data) => term.write(data),
});

transport.connect();

If you don't have a backend, @wterm/just-bash gives you an in-browser Bash shell using just-bash compiled to WASM. The @wterm/markdown package lets you stream Markdown into the terminal — the README shows a working Vercel AI SDK streaming example.

The vanilla (no framework) path works too: new WTerm(el, options) from @wterm/dom drops a terminal into any div.

Rough edges

The grid has a hard ceiling of 256×256 cells (MAX_COLS and MAX_ROWS in grid.zig). That's enough for most terminal use, but it'll silently clamp if you try to resize beyond that — there's no warning thrown.

The @wterm/just-bash package is thin on documentation. The README has a quick start, but the virtual FS API and network access story aren't well explained yet.

There's no 24-bit color support in the Zig core currently. The fg and bg fields are stored as u16, which maps to 256-color indices. The README lists "24-bit color — full RGB SGR support" as a feature, but the current cell struct doesn't have room for it — that appears to be aspirational.

Test coverage is solid for a v0.1.x release: 165 unit tests across all packages via Vitest, plus 11 Playwright E2E tests added in 0.1.8. The Zig side has its own test suite run via zig build test.

Bottom line

If you're building anything that needs a terminal in a browser — a web IDE, a cloud shell, an AI coding interface — wterm is the most technically coherent approach I've seen. The Zig-WASM core is small, auditable, and the DOM rendering gives you accessibility without fighting the browser.

vercel-labs/wterm on GitHub
vercel-labs/wterm