OpenTUI: Zig-Powered Terminal UI with Yoga Layout

November 1, 2025

|repo-review

by Florian Narr

OpenTUI: Zig-Powered Terminal UI with Yoga Layout

What it does

OpenTUI is a terminal UI framework written as a native Zig library with TypeScript bindings. The rendering hot path lives entirely in Zig, exposed over a C ABI via Bun's FFI. JavaScript (or React, or SolidJS, if you want) drives the component tree; Zig does the actual diffing and terminal output.

Why I starred it

Most TypeScript TUI libraries are turtles all the way down — Node.js streams writing ANSI codes, a JS diff loop, a JS render loop. That works, but you're always fighting the event loop when you want smooth animation or a responsive input widget in a busy terminal. OpenTUI makes a different bet: push the frame-level work into native code, expose a clean API to the JS side, and let the language boundaries do what they're good at.

The production proof is already there. OpenTUI powers OpenCode today. That's a working AI coding assistant built on this stack, not a demo.

How it works

The entry point is packages/core/src/zig.ts, which calls Bun's dlopen to load a platform-specific compiled Zig library (@opentui/core-${process.platform}-${process.arch}). From there, every meaningful operation — allocating buffers, writing cells, triggering a render — is a FFI call into the native layer.

The renderer in packages/core/src/zig/renderer.zig maintains two OptimizedBuffer instances: currentRenderBuffer and nextRenderBuffer. prepareRenderFrame in renderer.zig:609 does a cell-by-cell diff between them. Only changed cells get ANSI escape sequences emitted:

const charEqual = currentCell.?.char == nextCell.?.char;
const attrEqual = currentCell.?.attributes == nextCell.?.attributes;

if (charEqual and attrEqual and
    buf.rgbaEqual(currentCell.?.fg, nextCell.?.fg, colorEpsilon) and
    buf.rgbaEqual(currentCell.?.bg, nextCell.?.bg, colorEpsilon))
{
    continue; // skip — cell hasn't changed
}

The diff is wrapped in ANSI syncSet/cursor-hide/cursor-restore sequences and fed into a 2 MB output buffer. When useThread is set, rendering happens on a dedicated Zig thread with double-buffered output (outputBuffer / outputBufferB), signaled via a mutex/condition pair — so the JS event loop never blocks on stdout.

The character encoding model in packages/core/src/zig/grapheme.zig is worth reading on its own. Every cell's char field is a u32, but the top two bits are flags:

// 00xxxxxxxx: direct unicode scalar value (30 bits)
// 10xxxxxxxx: grapheme cluster, pool ID in lower 26 bits
// 11xxxxxxxx: continuation cell (wide character right half)
pub const CHAR_FLAG_GRAPHEME: u32 = 0x8000_0000;
pub const CHAR_FLAG_CONTINUATION: u32 = 0xC000_0000;

Multi-codepoint grapheme clusters (emoji with skin tone modifiers, ZWJ sequences) get interned into a slab allocator (GraphemePool) organized by size classes of 8, 16, 32, 64, and 128 bytes, with generation tagging to catch stale IDs. The comment in the source: "This is total overkill probably, but fun." It's not overkill if you want correct emoji rendering at 60fps.

On the TypeScript side, Renderable.ts is the base class for everything visible. It imports yoga-layout to handle flex positioning — the same Yoga engine that React Native uses for its layout pass. BoxRenderable, TextRenderable, Input, Diff, Markdown, ScrollBox, etc. all build on this. Absolute positioning, z-index, flex grow/shrink, percentage widths — it's all there, driven by the same constraints API you'd use in React Native.

The text editing layer in packages/core/src/zig/rope.zig uses a persistent/immutable rope data structure with undo/redo history and a marker system for tracking cursor positions across edits:

pub fn Rope(comptime T: type) type {
    // persistent tree — operations create new nodes without freeing old ones
    // markers track positions across mutations via a lazy cache
    ...
    pub const max_imbalance = 7;
}

The rope being persistent (copy-on-modify) means undo is trivially correct — just keep old root nodes — but the flip side is that you need an arena allocator to avoid leaking them. The source comment calls this out explicitly.

Using it

Requires Bun (uses bun:ffi for the native bindings), and you need Zig installed to build from source. Prebuilt binaries ship in the npm packages for macOS and Linux.

bun install @opentui/core

A minimal renderer:

import { createCliRenderer, BoxRenderable, TextRenderable } from "@opentui/core"

const renderer = await createCliRenderer({
  exitOnCtrlC: true,
  targetFps: 30,
})

const box = new BoxRenderable(renderer, {
  width: "auto",
  height: "auto",
  flexDirection: "row",
  backgroundColor: "#1e293b",
  border: true,
  borderStyle: "single",
})

const text = new TextRenderable(renderer, {
  content: "hello from zig",
  fg: "#f8fafc",
})

box.add(text)
renderer.root.add(box)

The React package wraps a custom reconciler (packages/react/src/reconciler/host-config.ts) that maps React tree operations to OpenTUI's imperative add/remove/update calls. SolidJS gets the same treatment in @opentui/solid.

Rough edges

Bun-only for now. The FFI layer uses bun:ffi directly, so Node.js support isn't on the table without a significant rewrite of zig.ts. If your project runs on Node, you're looking at the wrong library.

Building from source requires Zig to be installed, which adds a non-trivial step for contributors on teams that don't already have it. The prebuilt binaries in releases cover macOS arm64, macOS x64, and Linux x64 — no Windows native path yet (PowerShell users are pointed at downloading a release binary directly).

Docs are improving but still thin in places. The API surface is large — 30+ files in packages/core/src/renderables/ alone — and the website docs cover the basics without walking through the more interesting components like Diff, TextBuffer, or the NativeSpanFeed for streaming ANSI output into a buffer.

The React reconciler in packages/react/src/reconciler/host-config.ts is present but lightly documented. If you want to build React-based TUIs, you'll be reading that file directly.

Bottom line

OpenTUI is the right architecture for serious terminal applications in TypeScript — native render loop, correct Unicode, flex layout, React or SolidJS if you want it. If you're building on Bun and need a TUI with real performance characteristics, this is the current state of the art in the ecosystem.

anomalyco/opentui on GitHub
anomalyco/opentui