# W-Term: A DOM-Based Web Terminal from Vercel with Ghostty Core Support

[W-Term](https://github.com/vercel/w-term) is **a web terminal emulator from Vercel that renders to the DOM rather than to an HTML `<canvas>`**. Each terminal row becomes a `div.term-row` with actual text content, which means native browser text selection, copy-paste, `Ctrl+F` search, and screen reader parsing all work without any additional implementation.

The default rendering engine is a Zig-compiled WebAssembly binary of approximately 12KB. For applications requiring more complete VT-100 emulation, an optional Ghostty core (~400KB) is available as a drop-in replacement.

<iframe width="100%" height="315" src="https://www.youtube.com/embed/MxACQUVS5mE" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>



## Canvas-based terminals and why DOM rendering matters

Xterm.js, the dominant web terminal library, renders to a canvas element. The browser treats the canvas as a pixel bitmap with no semantic understanding of the text drawn inside it. This means standard browser features do not work natively: text selection, copy, find, and screen reader support all require re-implementation in JavaScript. The results are functional but not equivalent to native browser behavior.

W-Term's DOM approach solves this by keeping the terminal content as actual text nodes. The trade-off is that naive DOM manipulation can be slow. W-Term avoids this with dirty-row tracking: it uses `requestAnimationFrame` and only re-renders the specific rows that changed since the last frame, keeping performance competitive even for applications like `htop` that update frequently.

![htop running in W-Term with browser developer tools open showing the corresponding div elements for each row](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/903d3376-04e6-474c-ba8e-a4764b65cb00/md1x =1920x1080)

## Full-stack setup

Building a functional web terminal requires a frontend (W-Term) and a backend that runs a real shell process and connects to the frontend via WebSocket.

### Frontend: React with W-Term

Create a Vite React project:

```command
npm create vite@latest wterm-demo -- --template react-ts
```

```command
cd wterm-demo && npm install
```

Install W-Term packages:

```command
npm install @wterm/core @wterm/react @wterm/dom
```

- `@wterm/core`: framework-agnostic logic including the WebSocket transport
- `@wterm/dom`: the DOM rendering engine
- `@wterm/react`: the `<Terminal />` React component

Basic component:

```tsx
[label src/App.tsx]
import { Terminal } from "@wterm/react";
import "@wterm/react/css";

function App() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <Terminal />
    </div>
  );
}

export default App;
```

At this point, the terminal renders but is not connected to a shell. Typing produces no output.

### Backend: Node.js PTY server

The backend spawns a shell process using `node-pty`, which creates a pseudoterminal (PTY) master-slave pair. Node.js writes to the master end (appearing as stdin to the shell) and reads from the master end (capturing stdout and stderr). A WebSocket server bridges this to the browser.

```command
mkdir wterm-server && cd wterm-server && npm init -y
```

```command
npm install ws node-pty
```

```javascript
[label server.mjs]
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import * as pty from "node-pty";

const PORT = process.env.PORT || 3001;
const SHELL = process.env.SHELL || "bash";

const server = createServer((_, res) => {
  res.writeHead(404);
  res.end();
});

const wss = new WebSocketServer({ server, path: "/api/terminal" });

wss.on("connection", (ws) => {
  const proc = pty.spawn(SHELL, [], {
    name: "xterm-256color",
    cols: 80,
    rows: 24,
    cwd: process.env.HOME,
    env: process.env,
  });

  // PTY output -> WebSocket client
  proc.onData((data) => {
    if (ws.readyState === ws.OPEN) ws.send(data);
  });

  // WebSocket client input -> PTY
  ws.on("message", (data) => {
    const message = data.toString();
    const resizeMatch = message.match(/^\x1b\[RESIZE:(\d+);(\d+)\]$/);
    if (resizeMatch) {
      proc.resize(parseInt(resizeMatch[1], 10), parseInt(resizeMatch[2], 10));
    } else {
      proc.write(message);
    }
  });

  const handleClose = () => proc.kill();
  ws.on("close", handleClose);
  ws.on("error", handleClose);
});

server.listen(PORT, () => {
  console.log(`Terminal server running on http://localhost:${PORT}`);
});
```

![Full Node.js server script in a code editor](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/6b4dfb65-dc04-4fbf-c515-d76996760600/orig =1920x1080)

The resize handling uses a custom escape sequence (`\x1b[RESIZE:cols;rows]`) to distinguish resize events from regular input in the same WebSocket channel.

### Connecting the frontend to the backend

```tsx
[label src/App.tsx]
import { useRef } from "react";
import { Terminal } from "@wterm/react";
import { WebSocketTransport } from "@wterm/core";
import { WTerm } from "@wterm/dom";
import "@wterm/react/css";

const WS_URL = "ws://localhost:3001/api/terminal";

function App() {
  const transportRef = useRef<WebSocketTransport | null>(null);

  const handleReady = (wt: WTerm) => {
    const transport = new WebSocketTransport({
      url: WS_URL,
      reconnect: true,
      maxReconnectDelay: 5000,
    });
    transportRef.current = transport;

    transport.onData((data) => wt.write(data));
    transport.onOpen(() => {
      transport.send(`\x1b[RESIZE:${wt.cols};${wt.rows}]`);
    });
    transport.connect();
  };

  const handleData = (data: string) => transportRef.current?.send(data);

  const handleResize = (cols: number, rows: number) => {
    transportRef.current?.send(`\x1b[RESIZE:${cols};${rows}]`);
  };

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <Terminal
        cols={80}
        rows={24}
        autoResize={true}
        onReady={handleReady}
        onData={handleData}
        onResize={handleResize}
      />
    </div>
  );
}

export default App;
```

![Final App.tsx file showing the WebSocket connection logic](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/2249dbfe-5133-4426-b8b1-4ab5052e8600/lg2x =1920x1080)

Start the backend:

```command
node server.mjs
```

Start the frontend:

```command
npm run dev
```

The terminal now connects to the shell. Typing `ls`, `htop`, or any other shell command works as expected.

## Ghostty core

The default ~12KB Zig core handles standard terminal output well. For complex TUI applications, full Unicode grapheme cluster support, or advanced terminal attributes, the Ghostty core provides more complete VT-100 emulation at the cost of a larger bundle (~400KB).

![Side-by-side comparison of color rendering between the default core and the Ghostty core showing Ghostty's superior accuracy](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/400ff691-97b2-4d10-c296-bd954772da00/md2x =1920x1080)

### Switching to Ghostty

```command
npm install @wterm/ghostty
```

Add a script to copy the WASM file to the public directory before building:

```json
[label package.json]
"scripts": {
  "dev": "npm run copy-wasm && vite",
  "build": "npm run copy-wasm && tsc && vite build",
  "copy-wasm": "cp ./node_modules/@wterm/ghostty/dist/ghostty-vt.wasm ./public/"
}
```

Load the core asynchronously and pass it to the `Terminal` component:

```tsx
[label src/App.tsx]
import { useEffect, useState, useRef } from "react";
import { Terminal } from "@wterm/react";
import { WebSocketTransport } from "@wterm/core";
import { WTerm } from "@wterm/dom";
import { GhosttyCore } from "@wterm/ghostty";
import "@wterm/react/css";

const WS_URL = "ws://localhost:3001/api/terminal";

function App() {
  const [core, setCore] = useState<any>(null);
  const transportRef = useRef<WebSocketTransport | null>(null);

  useEffect(() => {
    GhosttyCore.load({ wasmPath: "/ghostty-vt.wasm" }).then(setCore);
  }, []);

  const handleReady = (wt: WTerm) => {
    const transport = new WebSocketTransport({ url: WS_URL, reconnect: true });
    transportRef.current = transport;
    transport.onData((data) => wt.write(data));
    transport.onOpen(() => transport.send(`\x1b[RESIZE:${wt.cols};${wt.rows}]`));
    transport.connect();
  };

  const handleData = (data: string) => transportRef.current?.send(data);
  const handleResize = (cols: number, rows: number) => {
    transportRef.current?.send(`\x1b[RESIZE:${cols};${rows}]`);
  };

  if (!core) return <div>Loading terminal core...</div>;

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      <Terminal
        core={core}
        cols={80}
        rows={24}
        autoResize={true}
        onReady={handleReady}
        onData={handleData}
        onResize={handleResize}
      />
    </div>
  );
}

export default App;
```

The `copy-wasm` script runs before Vite, placing the WASM file in `public/` where Vite's dev server can serve it. The `wasmPath` prop tells Ghostty where to fetch it at runtime.

## Final thoughts

**W-Term's DOM-based rendering is a meaningful architectural improvement over canvas-based terminals for most web applications**. Native text selection, find, and accessibility work without custom implementations. The dirty-row tracking keeps performance practical even for high-frequency output.

The choice between the Zig default core and the Ghostty core is a bundle size versus emulation completeness tradeoff. For most use cases, the 12KB default is sufficient. For cloud IDEs or developer tools where users will run complex TUI applications, the Ghostty core is the better choice.

The WebSocket + node-pty server pattern shown here is straightforward to extend: add authentication, session persistence, container-scoped shells, or multiplexing by building on top of the same bidirectional channel.

Source code and documentation are at [github.com/vercel/w-term](https://github.com/vercel/w-term).