Skip to content
Go back

OpenTUI: Responsive Terminal

OpenTUI is a terminal UI framework.

Play

Terminal applications don’t have to be limited to simple text output. With OpenTUI, you can build interfaces using concepts that feel very similar to frontend development. Containers behave like <div> elements, layouts are powered by Yoga (a Flexbox-inspired engine), and components can react dynamically to terminal size changes.

In this article, we’ll build:

Here’s the complete application we’ll be building:

import theme from "./theme";
import {
createCliRenderer,
BoxRenderable,
TextRenderable,
ASCIIFontRenderable,
type CliRenderer,
} from "@opentui/core";

Creating the Header

The first component is a header containing a large ASCII title and a subtitle.

const buildHeader = (renderer: CliRenderer) => {
const header = new BoxRenderable(renderer, {
id: "header",
flexDirection: "column",
alignItems: "center",
paddingTop: 1,
height: 9,
});
header.add(
new ASCIIFontRenderable(renderer, {
id: "title",
text: "TUI",
font: "block",
color: theme.blue,
maxWidth: "100%",
}),
);
header.add(
new TextRenderable(renderer, {
id: "subtitle",
content: "Terminal User Interface",
fg: theme.dim,
}),
);
return header;
};

If you’re coming from React or browser development, you can think of a BoxRenderable as a <div> and TextRenderable as a text node.

The ASCIIFontRenderable creates the large banner text:

████████╗██╗ ██╗██╗
╚══██╔══╝██║ ██║██║
██║ ██║ ██║██║
██║ ██║ ██║██║
██║ ╚██████╔╝██║
╚═╝ ╚═════╝ ╚═╝
Terminal User Interface

Building the Panels

Next, we’ll create two panels. Both have rounded borders and occupy 50% of the available width.

Panel A

const buildPanelA = (renderer: CliRenderer) => {
const panel = new BoxRenderable(renderer, {
id: "panel-a",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
width: "50%",
flexDirection: "column",
padding: 1,
title: "Panel A",
});
return panel;
};

Panel B

const buildPanelB = (renderer: CliRenderer) => {
const panel = new BoxRenderable(renderer, {
id: "panel-b",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
width: "50%",
flexDirection: "column",
padding: 1,
title: "Panel B",
});
return panel;
};

Because the components are nearly identical, they could eventually be replaced with a reusable panel component, but for clarity we’ll keep them separate.

The footer spans the entire width of the terminal and stays fixed regardless of screen size.

const buildFooter = (renderer: CliRenderer) => {
const bar = new BoxRenderable(renderer, {
id: "footer",
height: 1,
flexDirection: "row",
backgroundColor: theme.bgAlt,
paddingLeft: 1,
paddingRight: 1,
});
bar.add(
new TextRenderable(renderer, {
id: "status-text",
content: "footer here",
fg: theme.dim,
}),
);
return bar;
};

Creating the Content Area

Now we need somewhere to place the panels.

The content area contains a single row named “top-row”.

const buildContentArea = (renderer: CliRenderer) => {
const content = new BoxRenderable(renderer, {
id: "content",
flexDirection: "column",
flexGrow: 1,
});
const topRow = new BoxRenderable(renderer, {
id: "top-row",
flexDirection: "row",
gap: 1,
flexGrow: 1,
});
topRow.add(buildPanelA(renderer));
topRow.add(buildPanelB(renderer));
content.add(topRow);
return content;
};

Initially, the row flows horizontally:

┌──────────────┐ ┌──────────────┐
│ Panel A │ │ Panel B │
│ │ │ │
└──────────────┘ └──────────────┘

Building the Main UI

The root container fills the entire terminal.

const buildTui = (renderer: CliRenderer) => {
const main = new BoxRenderable(renderer, {
id: "main",
width: "100%",
height: "100%",
flexDirection: "column",
backgroundColor: theme.bg,
});
main.add(buildHeader(renderer));
main.add(buildContentArea(renderer));
main.add(buildFooter(renderer));
renderer.root.add(main);
};

At this point, the application is complete—but it isn’t responsive yet.

Handling Resize Events

OpenTUI emits resize events whenever the terminal dimensions change.

Inside our resize handler, we retrieve the elements we need by ID:

const handleResize = (renderer: CliRenderer, width: number) => {
const topRow = renderer.root.findDescendantById("top-row");
const panelA = renderer.root.findDescendantById("panel-a");
const panelB = renderer.root.findDescendantById("panel-b");
if (!topRow) return;
if (width < 100) {
topRow.flexDirection = "column";
if (panelA) panelA.width = "100%";
if (panelB) panelB.width = "100%";
} else {
topRow.flexDirection = "row";
if (panelA) panelA.width = "50%";
if (panelB) panelB.width = "50%";
}
};

The magic happens here:

topRow.flexDirection = “column”;

When the terminal becomes narrow, the row changes from horizontal to vertical.

Wide Terminal

┌──────────────┐ ┌──────────────┐
│ Panel A │ │ Panel B │
└──────────────┘ └──────────────┘

Narrow Terminal

┌──────────────┐
│ Panel A │
└──────────────┘
┌──────────────┐
│ Panel B │
└──────────────┘

Starting the Renderer

Finally, we create the renderer, build the UI, and register the resize handler.

const main = async () => {
const renderer = await createCliRenderer({
exitOnCtrlC: true,
});
buildTui(renderer);
renderer.on("resize", (width: number) => {
handleResize(renderer, width);
});
renderer.start();
};
main();

Why OpenTUI Feels Familiar

One of the nicest things about OpenTUI is that it borrows heavily from concepts frontend developers already know.

BrowserOpenTUI
<div>BoxRenderable
Text nodeTextRenderable
Hero headingASCIIFontRenderable
FlexboxYoga Layout
querySelector()findDescendantById()
Window resize eventsRenderer resize events

Because of this, building terminal applications feels surprisingly close to building web applications.

Final Thoughts

Responsive design isn’t limited to browsers.

Using OpenTUI and TypeScript, we were able to create:

If you’re already comfortable with React, Vue, or modern frontend development, OpenTUI provides a very natural way to bring those same ideas into the terminal.

And, of course, every terminal application deserves an unnecessarily large ASCII-art header.


Share this post on:

Previous Post
Apple's Built-in LLM Sucks, Mostly
Next Post
Build llama.cpp from source