この記事ではUIデザインに特化して解説します。デザインシステム・UI生成全般は デザインシステムとは?仕組み・構成要素・有名事例をエンジニア向けに完全解説 をご覧ください。

wtermとは - DOM描画のウェブターミナルエミュレータ

2026年4月14日、Vercel Labsがwterm(”ダブターム”)をOSSとして公開した。ウェブブラウザ上で動作するターミナルエミュレータで、リリースからわずか5日間で1,700スター超を獲得している。

wtermの最大の特徴は、ターミナル文字をCanvas2DではなくDOMに直接描画する点だ。これにより、ブラウザが本来持つテキスト選択、クリップボードのコピー・ペースト、Ctrl+Fによるページ内検索、スクリーンリーダーによるアクセシビリティ支援が追加実装なしに機能する。

コアはZig言語で書かれ、WASMにコンパイルされた約12KBのバイナリがVT100/VT220/xtermの制御シーケンスを解析する。TypeScriptのDOMレイヤーが、このWASMブリッジから受け取ったセル状態をrequestAnimationFrameで効率的に描画する。

バージョンは執筆時点でv0.1.8(2026年4月16日リリース)。165個のユニットテスト、11個のPlaywright E2Eテストが整備されており、実用性を高速に引き上げている段階のプロジェクトだ。

wtermの発音
"w"はウェブの"w"ではなく"double"を意味し、"dub-term"(ダブターム)と読む。Vercel Labsはリポジトリ名にも"wterm"という略称を採用している。

同じVercel Labsが開発したオープンエージェントフレームワークopen-agentsの解説記事も参考になる。Vercel Labsは近年、フロントエンド開発者向けの実験的OSSを積極的に公開している。

DOM描画が従来ターミナルと根本的に異なる理由

多くのウェブターミナルエミュレータはCanvas APIを使ってターミナル画面を1枚の絵として描画する。この手法は高いレンダリング自由度をもたらすが、ブラウザが提供するテキスト処理機能を使えなくなるという代償が伴う。

wtermはこのアプローチを逆転させた。各ターミナルセルを<span>要素としてDOMに配置し、CSSでスタイリングする。

DOM描画がもたらす恩恵:

  • ネイティブテキスト選択 — マウスドラッグで範囲選択、ダブルクリックで単語選択がブラウザ標準の動作で機能する
  • ネイティブクリップボード — Ctrl+C/Ctrl+Vがそのまま機能し、ブラウザのClipboard APIを別途実装する必要がない
  • ブラウザ内検索 — Ctrl+Fでページ内検索が動作する。SSHセッション内のログをCtrl+Fで検索できるのは実用上大きな優位性
  • スクリーンリーダー対応 — DOM要素として配置されているため、支援技術が読み上げ可能。アクセシビリティ準拠が必要なシステムで有効
  • CSSカスタマイズ — テーマをCSS Custom Propertiesで定義でき、ブランドカラーへの変更が容易
パフォーマンスのトレードオフ
DOM描画はCanvas描画と比較して大量の要素生成を伴う。wtermはdirty-row追跡(変更行のみ再描画)とrequestAnimationFrameによるバッチ更新でこれを緩和しているが、極めて高頻度な出力を持つコマンド(例:高速でスクロールするlogストリーム)では、Canvas描画と比較してCPU負荷が高くなる可能性がある。

Zig+WASMコアの技術設計

wtermのコアはZig 0.16.0で書かれ、WASMにコンパイルされた単一バイナリ(リリースビルドで約12KB)として配布される。このWASMモジュールが担うのはターミナルエミュレーションの中核ロジックだ。

WASMコアが処理する機能:

  • VT100/VT220/xterm制御シーケンスのパース
  • ターミナルグリッドのセル状態管理(文字コード、前景色、背景色、フラグ)
  • カーソル位置と可視状態の追跡
  • スクロールバック履歴のリングバッファ(設定可能)
  • オルタネートスクリーンバッファ(vim/less/htop向け)
  • 24ビットカラー(フルRGB SGR)サポート
  • ブラケットペーストモード
// zig build でWASMバイナリをビルド
zig build

// リリースビルド(~12KBに最適化)
zig build -Doptimize=ReleaseSmall

TypeScript側はWasmBridgeクラスを通じてWASMと通信する。WasmBridge.load()でWASMを非同期に初期化し、以降は同期APIでグリッド状態を読み出す設計だ。

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

// WASMをロードしてブリッジを初期化
const bridge = await WasmBridge.load();
bridge.init(80, 24);

// ターミナルにデータを書き込む
bridge.writeString("Hello, world!\r\n");

// セル状態を読み出す
const cell = bridge.getCell(0, 0); // { char, fg, bg, flags }
const cursor = bridge.getCursor();  // { row, col, visible }

// dirty-row追跡でどの行が再描画必要か確認
const needsRedraw = bridge.isDirtyRow(0);
bridge.clearDirty();

dirty-row追跡は特に重要な最適化だ。isDirtyRow(row)で変更のあった行だけを特定し、requestAnimationFrameループで必要な行だけをDOM更新する。全行を毎フレーム再描画することなく、差分のみを適用する設計になっている。

v0.1.3以降、WASMバイナリはbase64エンコードでJSバンドルに埋め込まれている(wasm-inline.js)。これによりwasmUrlオプションの設定が不要になり、npm installだけで即利用できる。

flowchart LR A["ユーザー入力
(キーボード)"] --> B["InputHandler
(@wterm/dom)"] B --> C["WasmBridge
(@wterm/core)"] C --> D["WASM
Zig製VT100パーサ"] D --> C C --> E["Renderer
(@wterm/dom)"] E --> F["DOM
(span要素群)"] G["PTYバックエンド
(WebSocket)"] --> C B --> G

5つのパッケージ構成と役割

wtermはモノレポ構成で5つのnpmパッケージとして提供される。各パッケージは独立して使用でき、必要なものだけインストールすればよい。

パッケージ 役割 依存
@wterm/core WASMブリッジ + WebSocketトランスポート(DOM不要)
@wterm/dom DOMレンダラー + 入力ハンドラー(バニラJS用)。@wterm/coreを再エクスポート @wterm/core
@wterm/react ReactコンポーネントとuseTerminalフック @wterm/dom
@wterm/just-bash ブラウザ内Bashシェル(just-bash実装) @wterm/dom
@wterm/markdown MarkdownをANSIシーケンスに変換してターミナル表示 @wterm/core

パッケージの選択指針:

  • フレームワーク不問のバニラJS → @wterm/dom@wterm/coreを含む)
  • Reactアプリ → @wterm/react@wterm/domを含む)
  • WebSocketなしにブラウザ内で動くシェルが欲しい → @wterm/just-bash
  • LLM出力やMarkdownをターミナル表示したい → @wterm/markdown
  • Headlessで使いたい(DOM不要) → @wterm/coreのみ
ヘッドレス用途のユースケース
@wterm/coreはDOM依存なしで使えるため、Node.jsサーバーサイドやテスト環境でのターミナルエミュレーション、e.g. PTY出力の文字列解析やVT100シーケンスのパースに利用できる。

バニラJSでの基本セットアップ

@wterm/domを使ったバニラJSでの最小セットアップを確認する。WASMバイナリはバンドルに埋め込まれているため、npm install後すぐに動作する。

npm install @wterm/dom

HTMLとJavaScriptでの基本実装:

<div id="terminal"></div>

<script type="module">
  import { WTerm } from "@wterm/dom";
  import "@wterm/dom/css";

  // ターミナルを初期化(デフォルト80x24、autoResize有効)
  const term = new WTerm(document.getElementById("terminal"));
  await term.init();

  // データを書き込む
  term.write("Hello, wterm!\r\n");
</script>

onDataコールバックを省略した場合、入力はそのまま折り返してターミナルに表示される(エコーバック)。WebSocketやPTYと接続する場合はonDataでデータの行き先を制御する。

WTermのコンストラクタオプション:

const term = new WTerm(element, {
  cols: 80,           // 初期列数(デフォルト: 80)
  rows: 24,           // 初期行数(デフォルト: 24)
  wasmUrl: "/wterm.wasm",  // WASMを別途配信する場合(省略可)
  autoResize: true,   // コンテナに合わせて自動リサイズ(デフォルト: true)
  cursorBlink: false, // カーソルブリンク(デフォルト: false)
  onData: (data) => { /* キー入力イベント */ },
  onTitle: (title) => { /* タイトル変更イベント */ },
  onResize: (cols, rows) => { /* リサイズイベント */ },
});

await term.init();

ローカル開発サーバーでの動作確認:

git clone https://github.com/vercel-labs/wterm
cd wterm
pnpm install
zig build

# バニラデモを実行
cd web && python3 -m http.server 8000

ReactコンポーネントとuseTerminalフック

@wterm/reactはReact 19のCallback Refパターンを活用しており、useEffectを使わずに命令的なDOMライブラリの初期化を実現している。

npm install @wterm/dom @wterm/react

最小構成の使い方:

import { Terminal } from "@wterm/react";
import "@wterm/react/css";

function App() {
  return <Terminal />;
}

WebSocketと組み合わせる場合はuseTerminalフックでターミナルへの命令的アクセスを取得する:

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

function WebTerminal({ wsUrl }: { wsUrl: string }) {
  const { ref, write } = useTerminal();
  const socketRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const ws = new WebSocket(wsUrl);
    socketRef.current = ws;

    ws.binaryType = "arraybuffer";
    ws.onmessage = (e) => {
      // バックエンドから受信したデータをターミナルに書き込む
      if (typeof e.data === "string") {
        write(e.data);
      } else {
        write(new Uint8Array(e.data));
      }
    };

    return () => ws.close();
  }, [wsUrl, write]);

  return (
    <Terminal
      ref={ref}
      cols={120}
      rows={36}
      theme="monokai"
      autoResize={false}
      cursorBlink={true}
      onData={(data) => {
        // ユーザーの入力をWebSocket経由でバックエンドに送信
        socketRef.current?.send(data);
      }}
      onTitle={(title) => {
        document.title = title;
      }}
      onResize={(cols, rows) => {
        // リサイズをバックエンドに通知(PTYのWINSZ更新)
        socketRef.current?.send(JSON.stringify({ type: "resize", cols, rows }));
      }}
    />
  );
}

<Terminal>の主要Props一覧:

Prop デフォルト 説明
cols number 80 初期列数
rows number 24 初期行数
wasmUrl string WASMを別途配信する場合のURL
theme string テーマ名(solarized-dark/monokai/light
autoResize boolean false コンテナサイズに合わせて自動リサイズ
cursorBlink boolean false カーソルブリンク
onData (data: string) => void キー入力コールバック。省略時は自動エコー
onReady (wt: WTerm) => void WASM初期化完了後のコールバック

useTerminalフックの返り値:

const { ref, write, resize, focus } = useTerminal();
// ref   — <Terminal ref={ref}> に渡す
// write — ターミナルにデータを書き込む(string | Uint8Array)
// resize — ターミナルをリサイズ(cols, rows)
// focus  — ターミナルにフォーカスを移す

TerminalHandleインターフェースを通じてref.current.instanceからWTerm本体にもアクセスできるため、onReadyコールバックを経由せずに高度な操作も可能だ。

Next.jsでの注意点
@wterm/reactはブラウザAPIを使用するため、Next.jsでは'use client'ディレクティブが必要。また、WASMの動的インポートがApp RouterのSSRと干渉する場合はdynamic importでSSRを無効化する(ssr: false)。

WebSocketでPTYバックエンドに接続する

wtermの実用的なユースケースの中心は、WebSocket経由でサーバー側のPTY(Pseudo Terminal)に接続するパターンだ。公式リポジトリにはSSHクライアントとして機能するNext.jsサンプルが含まれている。

@wterm/coreのWebSocketTransport:

import { WTerm, WebSocketTransport } from "@wterm/dom";

const term = new WTerm(document.getElementById("terminal")!, {
  cols: 80,
  rows: 24,
});
await term.init();

// WebSocketトランスポートを作成
const transport = new WebSocketTransport({
  url: "ws://localhost:8080/pty",
  onData: (data) => term.write(data),  // バックエンド → ターミナル
});

transport.connect();

// ターミナル → バックエンド
term.onData = (data) => transport.send(data);

Node.jsのPTYバックエンド側の実装例(ssh2ライブラリ使用):

// server.ts(Node.js)
import { WebSocketServer, WebSocket } from "ws";
import { Client } from "ssh2";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws) => {
  const sshClient = new Client();

  // 接続パラメータを最初のメッセージから受け取る
  ws.once("message", (data) => {
    const params = JSON.parse(data.toString());

    sshClient.connect({
      host: params.host,
      port: params.port,
      username: params.username,
      password: params.password,
    });

    sshClient.on("ready", () => {
      sshClient.shell(
        { term: "xterm-256color", cols: 80, rows: 24 },
        (err, stream) => {
          // SSHストリーム → WebSocket → ブラウザのwterm
          stream.on("data", (chunk: Buffer) => {
            if (ws.readyState === WebSocket.OPEN) {
              ws.send(chunk.toString("binary"));
            }
          });

          // ブラウザのwterm → WebSocket → SSHストリーム
          ws.on("message", (msg) => {
            stream.write(msg.toString());
          });

          ws.on("close", () => {
            stream.end();
            sshClient.end();
          });
        }
      );
    });
  });
});
sequenceDiagram participant B as ブラウザ
wterm participant WS as WebSocket
サーバー participant PTY as PTY
プロセス B->>WS: WebSocket接続確立 B->>WS: 接続パラメータJSON送信 WS->>PTY: シェル/SSH起動 PTY-->>WS: 初期プロンプト出力 WS-->>B: バイナリデータ転送 Note over B: term.write()でDOM更新 B->>WS: キー入力データ送信 WS->>PTY: 入力転送 PTY-->>WS: コマンド出力 WS-->>B: バイナリデータ転送 Note over B: dirty-rowのみ再描画

テーマとCSSカスタマイズ

wtermのテーマシステムはCSS Custom Propertiesで構築されており、@wterm/react/cssまたは@wterm/dom/cssをインポートするとデフォルトテーマとビルトインテーマが適用される。

ビルトインテーマ:

  • default — デフォルト(ダーク)
  • solarized-dark — Solarized Dark
  • monokai — Monokai
  • light — ライトモード

Reactコンポーネントでのテーマ切り替え:

<Terminal theme="solarized-dark" />

カスタムテーマの定義(CSS Custom Properties):

/* カスタムテーマをCSSで定義 */
.wterm[data-theme="my-theme"] {
  --term-bg: #1e1e2e;
  --term-fg: #cdd6f4;
  --term-cursor: #f5e0dc;

  /* ANSIカラー(0-15) */
  --term-color-0: #45475a;   /* black */
  --term-color-1: #f38ba8;   /* red */
  --term-color-2: #a6e3a1;   /* green */
  --term-color-3: #f9e2af;   /* yellow */
  --term-color-4: #89b4fa;   /* blue */
  --term-color-5: #f5c2e7;   /* magenta */
  --term-color-6: #94e2d5;   /* cyan */
  --term-color-7: #bac2de;   /* white */
  /* 8-15: bright variants */
}

v0.1.6で追加された--term-row-heightCSS変数により、行の高さを1箇所で統一管理できるようになった。

@wterm/just-bashでブラウザ内Bashを動かす

@wterm/just-bashパッケージはブラウザ内で動くBashシェルをwtermと組み合わせて提供する。Node.jsサーバーやWebSocketバックエンドなしに、完全にクライアントサイドで動作するシェル環境を構築できる。

npm install @wterm/dom @wterm/just-bash
import { WTerm } from "@wterm/dom";
import { JustBash } from "@wterm/just-bash";
import "@wterm/dom/css";

const term = new WTerm(document.getElementById("terminal")!);
await term.init();

const bash = new JustBash({ terminal: term });
await bash.start();

just-bashはローカルファイルシステムの仮想FS(Virtual File System)を持ち、lscatechomkdirなど基本的なBashコマンドをブラウザ内で実行できる。ネットワークアクセスやインタラクティブプログラム(vim等)の動作には制限がある。

Viteでのセットアップ例(公式サンプルより):

# wterm公式リポジトリからViteサンプルを参照
pnpm --filter vite dev

バックエンドレスで動くターミナルが必要なデモサイト、インタラクティブなドキュメント、オンラインコードプレイグラウンドなどに適している。

@wterm/markdownでLLM出力をターミナル表示

@wterm/markdownはMarkdown文字列をANSIエスケープシーケンスに変換し、wtermのターミナル上に整形表示するパッケージだ。v0.1.8では「Markdown Streaming」サンプルが追加され、LLMのストリーミング出力をリアルタイムでターミナル表示するパターンのリファレンス実装が公開された。

このサンプルはNext.js + @wterm/react + @wterm/markdown + Vercel AI SDKを組み合わせている。AI SDKのストリーミングAPIから受け取った差分テキストを逐次write()に渡すことで、レスポンスが生成される様子をターミナル風に表示できる。

AIエージェントUIへの応用
wtermとLLMを組み合わせるパターンは、AIエージェントの実行ログをリアルタイムでユーザーに見せるUIに適している。エージェントの思考過程、ツール呼び出し結果、エラーメッセージをターミナル風に流すことで、開発者ツールらしい透明性のあるUXが実現できる。

xterm.js代替として使えるか:wterm・xterm.js・hTerm機能比較

ウェブターミナルエミュレータの主要3選を機能軸で比較する。

項目 wterm xterm.js hTerm
描画方式 DOM(span要素) Canvas 2D(+ WebGL) DOM
コア言語 Zig + WASM TypeScript JavaScript
バンドルサイズ ~12KB(WASM) ~500KB+ 大きい
テキスト選択 ネイティブ 独自実装 ネイティブ
Ctrl+F検索 ブラウザ標準 別途実装要
スクリーンリーダー ネイティブ対応 追加実装要 部分対応
React統合 公式パッケージ サードパーティ
ブラウザ内シェル @wterm/just-bash
Markdown表示 @wterm/markdown
WebSocketトランスポート 公式パッケージ 別途実装 別途実装
成熟度・実績 新規(v0.1.8) 高(VS Code採用) 高(ChromeOS)
エコシステム 成長中 大(xterm-addon-*)

xterm.jsはVS Codeのターミナルに採用されており、成熟したエコシステムと豊富なアドオンが強みだ。Canvas WebGLによる高速描画は大量テキスト出力でも安定する。

wtermはDOM描画によるアクセシビリティと、WASMコアの小さなバイナリサイズが特徴。Reactとの公式統合、ブラウザ内シェル、Markdownレンダリングなど、ウェブアプリに組み込みやすいエコシステムを整備中だ。

既存のプロダクションシステムでxterm.jsを採用しているプロジェクトがwtermに移行する必要はない。新規プロジェクトでウェブターミナルを組み込む場合、特にアクセシビリティ要件があるか、Reactアプリに自然に統合したい場合はwtermが有力な選択肢になる。

v0.1.8の主要変更点と今後の動向

v0.1.8(2026年4月16日)は機能追加とテスト整備が中心のリリースだ。

v0.1.8の主な変更:

  • Playwright E2Eテスト — 11テスト追加(レンダリング、キーボード入力、フォーカス、カーソル移動、スクロールバック、リサイズ)
  • Viteサンプル@wterm/dom@wterm/just-bashを使ったフレームワーク不要の最小デモ
  • Markdownストリーミングサンプル — Next.js + AI SDKとの統合デモ
  • APIリファレンスページ — 全オプション、Props、メソッドを一箇所に統合
  • Zig 0.16.0移行 — ビルドシステムを最新Zigバージョンに更新
  • 165ユニットテスト — 5パッケージ全体でのテストカバレッジ整備

GitHubのオープンPR(執筆時点)には@wterm/search(グリッドとスクロールバックのテキスト検索)と@wterm/serialize(セッション永続化と復元)の追加が検討されている。これはxterm.jsのserialize pluginに相当する機能で、ユーザーからのフィードバックを受けた対応だ。

現時点での制限事項として、v0.1.x系はまだ安定版には至っていない。APIは変更の可能性があり、プロダクション採用には変更履歴を追う必要がある。また、極めて高速なターミナル出力(例:catで大容量ファイル)でのDOM描画パフォーマンスは、WebGL描画のxterm.jsに劣る場面がある。

開発環境のセットアップ

wtermのコア開発やカスタマイズにはZigとpnpmが必要だ。

# 前提:Zig 0.16.0+、Node.js 20+、pnpm 10+
# portlessはdev serverに必要
npm i -g portless

# リポジトリのクローンとセットアップ
git clone https://github.com/vercel-labs/wterm
cd wterm
pnpm install

# WASMバイナリのビルド
zig build

# 全パッケージのビルド
pnpm build

# Next.jsサンプルの実行(portlessで.localhostドメインが付与される)
cp web/wterm.wasm examples/nextjs/public/
pnpm --filter nextjs dev
# → nextjs-example.wterm.localhost でアクセス

# Zigテストの実行
zig build test
portlessとは
wtermのdev環境はportlessを使ってローカルサービスに.localhostサブドメインを付与している。ポート番号のハードコードを避けるVercel Labs謹製のシンプルなCLIツール。

同じVercel Labsが開発するOSSとして、Docker不要でAWS・Slack・GitHubをエミュレートするVercel Emulateや、AIエージェント向けRust製ブラウザ自動化CLIのagent-browserも参考になる。Vercel Labsはウェブ開発者向けの実験的OSSを積極公開しており、wterm・Emulate・agent-browserを組み合わせることでフロントエンド開発・テスト・ブラウザ自動化のスタックを統合できる。

まとめ

wtermはDOM描画とZig+WASMコアの組み合わせにより、ウェブターミナルのアクセシビリティ問題に正面から取り組んだOSSだ。

選定ポイントのまとめ:

  • アクセシビリティ必須 → wtermはブラウザネイティブのテキスト選択・スクリーンリーダー対応が強み
  • 新規Reactプロジェクト@wterm/reactで素直に統合できる
  • バックエンドレス端末@wterm/just-bashでサーバー不要のシェル環境
  • LLM出力のターミナル表示@wterm/markdownがストリーミング対応
  • プロダクション・大規模プロジェクト → 成熟したxterm.jsの方が安定

リリースからわずか5日で1,700スターを獲得したことは、ウェブターミナルエミュレータに対するコミュニティの需要の高さを示している。v0.1.x系の現段階でもパッケージ構造とAPIは整理されており、Next.js/Viteサンプルと合わせて評価しやすい状態にある。

参照ソース