Terminal user interfaces are having a renaissance. Tools like lazygit, k9s, and AI coding agents such as opencode have shown that a well-designed TUI can be faster and more focused than a browser tab. OpenTUI is the framework behind several of these experiences: a TypeScript library with a native rendering core written in Zig, component-based architecture, flexbox layout, and first-class bindings for React and Solid.
In this tutorial you will build a real terminal dashboard using OpenTUI's React bindings — the same declarative model you already know from the web, rendered to the terminal instead of the DOM. By the end you will understand the render loop, layout system, keyboard handling, and animation hooks well enough to ship your own CLI tools.
Prerequisites
Before starting, make sure you have:
- Bun 1.1+ installed (
bun --version) — OpenTUI's native core resolves fastest on Bun, though Node 20+ also works - Comfort with TypeScript and React hooks (
useState,useEffect) - A terminal that supports truecolor (most modern terminals do)
- A code editor — VS Code is recommended
You do not need to know Zig. The native core ships as a prebuilt binary and you interact with it entirely through TypeScript.
What You'll Build
By the end of this tutorial you will have a live system-monitor dashboard that runs in your terminal, featuring:
- A bordered layout split into panels using flexbox.
- A header rendered with a large ASCII font.
- Live-updating memory and load stats.
- A selectable menu navigated with the keyboard.
- A smooth animated progress bar driven by OpenTUI's timeline.
- Clean shutdown on pressing the
qkey.
Step 1: Project Setup
Create a fresh project and install the core plus the React binding. OpenTUI is split into small packages so you only pull in what you use.
mkdir opentui-dashboard && cd opentui-dashboard
bun init -y
bun add @opentui/core @opentui/react reactThe two packages you care about are:
@opentui/core— the renderer, layout engine, and low-level renderable components@opentui/react— the reconciler that lets you describe your UI with JSX
Add a dev script to package.json so you can run the app with hot reload:
{
"scripts": {
"dev": "bun --hot run src/index.tsx"
}
}Step 2: Your First Render
OpenTUI needs two things: a renderer (which owns the terminal, the frame buffer, and the input stream) and a root (which reconciles your React tree into that renderer). Create src/index.tsx:
/** @jsxImportSource @opentui/react */
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<box style={{ border: true, padding: 1 }}>
<text>Hello from the terminal</text>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)Run it:
bun run devYou should see a bordered box with your greeting. A few things are worth noting here. The jsxImportSource pragma at the top tells the compiler to use OpenTUI's JSX runtime instead of React DOM — this is what maps <box> and <text> to terminal renderables. The intrinsic elements are lowercase (box, text, input, select), and every visual element accepts a style prop.
Tip: If you prefer not to write the pragma in every file, set
"jsxImportSource": "@opentui/react"in yourtsconfig.jsonundercompilerOptionsand it applies project-wide.
Step 3: Layout with Flexbox
OpenTUI's layout engine is a flexbox implementation, so the mental model transfers directly from CSS. Boxes are flex containers; you control direction, sizing, and spacing with familiar properties. Let's split the screen into a header and a two-column body.
function Dashboard() {
return (
<box style={{ flexDirection: "column", height: "100%" }}>
<box style={{ border: true, padding: 1 }}>
<text>SYSTEM MONITOR</text>
</box>
<box style={{ flexDirection: "row", flexGrow: 1 }}>
<box style={{ border: true, flexGrow: 1, padding: 1 }}>
<text>Left panel — stats</text>
</box>
<box style={{ border: true, flexGrow: 1, padding: 1 }}>
<text>Right panel — menu</text>
</box>
</box>
</box>
)
}The outer box stacks children vertically (flexDirection: "column") and fills the terminal height. The body box lays its two panels side by side (flexDirection: "row"), and flexGrow: 1 makes each panel expand to share the available width equally. Resize your terminal and the layout reflows automatically — no manual math required.
Step 4: Live State with React Hooks
Because the React bindings run a real reconciler, standard hooks work exactly as you expect. Let's add memory and load statistics that refresh every second. We read them from Node's os module, which Bun exposes.
import { useState, useEffect } from "react"
import os from "node:os"
function useSystemStats() {
const [stats, setStats] = useState({ usedPct: 0, load: 0 })
useEffect(() => {
const tick = () => {
const total = os.totalmem()
const free = os.freemem()
const usedPct = Math.round(((total - free) / total) * 100)
const load = os.loadavg()[0]
setStats({ usedPct, load: Number(load.toFixed(2)) })
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [])
return stats
}Now render those values in the left panel. Notice how fg sets the foreground color and how we compose text nodes just like React on the web.
function StatsPanel() {
const { usedPct, load } = useSystemStats()
return (
<box style={{ border: true, flexGrow: 1, padding: 1, flexDirection: "column" }}>
<text style={{ fg: "#7dd3fc" }}>Memory used</text>
<text>{usedPct}%</text>
<text style={{ fg: "#c4b5fd" }}>Load average (1m)</text>
<text>{load}</text>
</box>
)
}The panel now updates once per second. Because OpenTUI diffs the virtual tree and only repaints changed cells, these updates are cheap even at high refresh rates.
Step 5: Keyboard Navigation and a Menu
Interactive TUIs live or die by their keyboard handling. OpenTUI ships a useKeyboard hook for raw key events and a <select> component for menus. First, wire up a global quit key using useRenderer to access the active renderer.
import { useKeyboard, useRenderer } from "@opentui/react"
function useQuitOnQ() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "q" || key.name === "escape") {
renderer.destroy()
process.exit(0)
}
})
}Next, add a menu on the right. The <select> component handles arrow-key navigation and emits the chosen option through onSelect.
function MenuPanel() {
const [selected, setSelected] = useState("Overview")
return (
<box style={{ border: true, flexGrow: 1, padding: 1, flexDirection: "column" }}>
<text style={{ fg: "#a7f3d0" }}>View — current: {selected}</text>
<select
style={{ height: 6 }}
options={[
{ name: "Overview", description: "Summary of all metrics" },
{ name: "Processes", description: "Top running processes" },
{ name: "Network", description: "Interface throughput" },
{ name: "Disk", description: "Volume usage" },
]}
onSelect={(index, option) => setSelected(option.name)}
/>
</box>
)
}A <select> needs to be focused to receive keys. When it is the only interactive element, OpenTUI focuses it automatically; with multiple focusable widgets you manage focus explicitly, which we cover in the troubleshooting section.
Step 6: Animated Progress with the Timeline
Static bars are fine, but a smooth animation communicates activity. OpenTUI's useTimeline hook animates any numeric value over time with easing — think of it as a lightweight tweening engine. Here we animate a fill width to visualize the memory percentage.
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function ProgressBar({ target }: { target: number }) {
const [width, setWidth] = useState(0)
const timeline = useTimeline({ duration: 600, loop: false })
useEffect(() => {
timeline.add(
{ width },
{
width: target,
duration: 600,
ease: "outCubic",
onUpdate: (anim) => setWidth(Math.round(anim.targets[0].width)),
},
)
}, [target])
return (
<box style={{ flexDirection: "row" }}>
<box style={{ width, height: 1, backgroundColor: "#22d3ee" }} />
<text> {target}%</text>
</box>
)
}Each time target changes, the timeline tweens the bar to its new width over 600 milliseconds with a cubic ease-out, so the dashboard feels alive rather than snapping between frames.
Step 7: Compose the Full Dashboard
Now assemble everything into the final tree. The root wires the quit handler, renders the ASCII header, and places the stats and menu panels side by side.
/** @jsxImportSource @opentui/react */
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
useQuitOnQ()
const { usedPct } = useSystemStats()
return (
<box style={{ flexDirection: "column", height: "100%" }}>
<box style={{ border: true, padding: 1 }}>
<ascii-font text="MONITOR" font="tiny" />
</box>
<box style={{ flexDirection: "row", flexGrow: 1 }}>
<box style={{ flexDirection: "column", flexGrow: 1 }}>
<StatsPanel />
<box style={{ border: true, padding: 1 }}>
<ProgressBar target={usedPct} />
</box>
</box>
<MenuPanel />
</box>
<box style={{ padding: 1 }}>
<text style={{ fg: "#94a3b8" }}>Press q to quit</text>
</box>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)Run bun run dev and you have a live, keyboard-driven dashboard: an ASCII header, self-updating stats, an animated memory bar, and a navigable menu — all in under 150 lines of TypeScript.
Testing Your Implementation
Verify the app behaves correctly:
- Layout reflow: Resize the terminal window. Panels should redistribute width without corrupting the borders.
- Live updates: Watch the memory percentage. Open a heavy application and confirm the number and bar respond within a second.
- Navigation: Use the arrow keys to move through the menu; the "current" label should track your selection.
- Clean exit: Press
q. The terminal should restore your prompt with the cursor visible and no leftover artifacts.
If the cursor stays hidden after exit, it means renderer.destroy() did not run — always call it before process.exit.
Troubleshooting
Garbled output or missing colors. Your terminal may not advertise truecolor. Set COLORTERM=truecolor in your environment, or fall back to 256-color styles.
Keys not registering. Only focused widgets receive key events. If you have more than one interactive element, call .focus() on the intended target, or manage focus with the keymap package (@opentui/keymap), which maps named commands to key bindings and handles focus rings for you.
Flicker on rapid updates. OpenTUI batches renders per frame, but calling setState in a tight synchronous loop can still thrash. Throttle updates to a sensible interval — once per 100 to 1000 milliseconds is plenty for a dashboard.
Node instead of Bun. The core also runs on Node 20+, but make sure the prebuilt native binary matches your platform; reinstall dependencies if you switched runtimes.
Next Steps
You now have the core mental model: a renderer, a React root, flexbox layout, keyboard hooks, and the timeline. From here you can:
- Add a scrollable log panel with the
<scrollbox>component for streaming output. - Render syntax-highlighted snippets using the
<code>construct with aSyntaxStyle. - Extract reusable widgets into an internal component library, or explore community kits built on the core.
- Swap the React binding for Solid if you prefer fine-grained reactivity — the core is identical.
For deeper dives, see our related guides on building a Node.js TypeScript CLI tool and the OpenCode terminal AI coding agent, which is itself built on OpenTUI.
Conclusion
OpenTUI brings the declarative, component-based ergonomics of modern web development to the terminal, backed by a fast native core. In this tutorial you built a complete system-monitor dashboard: you set up the renderer, laid out panels with flexbox, wired live state with React hooks, handled keyboard navigation, and animated a progress bar with the timeline. The same primitives scale from a quick internal utility to full-featured tools like opencode. The terminal is once again a first-class UI surface — and with OpenTUI, building for it feels like building for the web.