Back to Scaling Node.js applications guides

OpenTUI: React-Based Terminal UIs with a Zig Rendering Core

Stanley Ulili
Updated on June 1, 2026

OpenTUI is a terminal UI library built on Bun that allows developers to write TUI components in TypeScript using React or Solid.js. The rendering engine is written in Zig and communicates with the JavaScript layer through Bun's Foreign Function Interface (FFI), avoiding the overhead of pure JavaScript rendering.

Why a new TUI library

Ink is the established standard for React-based terminal UIs, used by GitHub Copilot CLI, Cloudflare Wrangler, and Prisma's CLI. Two characteristics make it unsuitable for high-throughput applications:

Memory usage. Applications built with Ink typically consume over 50MB of RAM due to Node.js runtime overhead and React's reconciliation process operating in a terminal context.

Frame rate cap. Ink throttles rendering to 32 FPS.

Screenshot of Ink's source code showing the throttle(this.onRender, 32) line which hardcodes the 32 FPS cap

For applications streaming large amounts of output continuously (coding agent output, log viewers), 32 FPS produces visible sluggishness. OpenTUI was built by Anomaly (the team behind OpenCode) to address these limitations for their rewrite from Go/Bubble Tea to TypeScript.

Architecture

Zig core. The rendering engine, layout manager, and event processor are written in Zig. This handles drawing to the terminal, managing the Flexbox layout (via the Yoga engine), and processing keyboard events.

TypeScript bindings. Application components are written in TypeScript with React or Solid.js. The standard component model, hooks, and state management all work as expected.

Bun FFI. Bun's FFI connects the TypeScript layer to the Zig core with near-zero overhead.

Bun FFI documentation page illustrating its purpose to efficiently call native libraries from JavaScript

This removes the performance ceiling imposed by pure JavaScript rendering. React components communicate with the Zig core at native speed.

Project setup

OpenTUI requires the Bun runtime.

 
curl -fsSL https://bun.sh/install | bash

Scaffold a new project:

 
bun create tui

Terminal showing the bun create tui command starting the interactive project setup wizard

The wizard asks for a project name and a template. Available templates: Core (no framework), React, Solid, or a custom GitHub URL. The following examples use the React template.

Initial project structure

The generated src/index.tsx:

src/index.tsx
import { createCliRenderer, TextAttributes } from "@opentui/core";
import { createRoot } from "@opentui/react";

function App() {
  return (
    <box alignItems="center" justifyContent="center" flexGrow={1}>
      <box justifyContent="center" alignItems="flex-end">
        <ascii-font font="tiny" text="OpenTUI" />
        <text attributes={[TextAttributes.DIM]}>What will you build?</text>
      </box>
    </box>
  );
}

const renderer = await createCliRenderer();
createRoot(renderer).render(<App />);

Initial src/index.tsx code generated by the React template showing the App component and renderer setup

The primitive components (<box>, <text>, <ascii-font>) replace HTML elements. Layout props (alignItems, justifyContent, flexGrow, flexDirection) work like CSS Flexbox via the Yoga engine.

createCliRenderer() initializes the Zig rendering engine. createRoot(renderer).render(<App />) is the equivalent of ReactDOM.createRoot(...).render(<App />) for the terminal.

 
bun run dev

Interactive input

React's useState integrates with OpenTUI's input component directly.

src/index.tsx
import { useState } from "react";
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";

function App() {
  const [name, setName] = useState("");

  return (
    <box
      padding={1}
      flexGrow={1}
      flexDirection="column"
      alignItems="center"
      justifyContent="center"
    >
      <text>Hello, {name || "world"}!</text>
      <box border title="Name" paddingLeft={1} paddingRight={1} marginTop={1}>
        <input
          placeholder="Enter your name..."
          onInput={setName}
          focused
        />
      </box>
    </box>
  );
}

const renderer = await createCliRenderer();
createRoot(renderer).render(<App />);

Updated React code for the interactive Hello User example showing useState and the input component

onInput={setName} fires with the current input value on each keystroke. focused gives the field focus on mount. The <box border title="Name"> wrapper renders a bordered container with a title label.

Animations with useTimeline

OpenTUI provides a useTimeline hook for frame-accurate animations. The following example fades and slides in a greeting after the user submits the input form.

src/index.tsx
import { useTimeline } from "@opentui/react";
import { useEffect, useState } from "react";

function Greeting({ name }: { name: string }) {
  const [offset, setOffset] = useState(8);
  const [opacity, setOpacity] = useState(0);

  const timeline = useTimeline({ duration: 800 });

  useEffect(() => {
    timeline.add({
      targets: [{ offset: 8, opacity: 0 }],
      offset: 0,
      opacity: 1,
      ease: "outQuad",
      onUpdate: (values) => {
        setOffset(Math.round(values.targets[0].offset));
        setOpacity(values.targets[0].opacity);
      },
    });
  }, []);

  return (
    <box marginTop={offset} opacity={opacity} flexDirection="column" alignItems="center">
      <ascii-font font="tiny" text={`Hello ${name}`} />
      <text marginTop={1}>Let's get this party started</text>
    </box>
  );
}

Code for the Greeting component highlighting the useTimeline hook and the useEffect block where the animation is defined

offset and opacity are React state values applied as component props. timeline.add() defines start values, end values, easing, and an onUpdate callback that receives interpolated values on each frame. The callback updates state, which causes React to re-render the component with the new prop values. This produces smooth motion at the Zig core's frame rate.

To wire this into the form, add a submitted state and conditional rendering:

src/index.tsx
const [submitted, setSubmitted] = useState(false);

// In render:
{submitted ? (
  <Greeting name={name} />
) : (
  <input
    onInput={setName}
    onSubmit={() => setSubmitted(true)}
    focused
  />
)}

onSubmit fires when the user presses Enter.

Comparison with alternatives

vs. Ink. OpenTUI removes the 32 FPS cap and reduces memory overhead. The developer experience is similar since both use React, but OpenTUI requires Bun rather than Node.js.

vs. native libraries (Bubble Tea, Ratatui). Go and Rust libraries produce smaller binaries and have marginally better raw performance, but they require learning a different language and UI paradigm. OpenTUI delivers sufficient performance for demanding TUIs while staying in the TypeScript/JSX ecosystem.

Final thoughts

OpenTUI is most valuable for teams already working in TypeScript who need a TUI with real-time output or interactive components where Ink's frame rate cap creates a visible problem. The React component model, familiar hooks, and Flexbox layout make the transition from web to terminal UI development straightforward.

For simpler CLIs that display static output or have minimal interaction, Ink remains a reasonable choice with a larger ecosystem and more community resources. OpenTUI's Bun dependency is also a consideration for projects that need to run in environments where only Node.js is available.

Source code and documentation are at github.com/opentui/opentui.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.