Component Model
OpenTUI has two ways to create UI: Renderables (class instances) and Constructs (factory functions that create virtual nodes). Both produce the same underlying renderable tree.
Renderables
A Renderable is a class instance with a Yoga layout node, props, children, and event handlers. You create one by instantiating the class directly:
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
const box = new BoxRenderable(renderer, {
id: "container",
flexDirection: "column",
padding: 1,
border: true,
borderStyle: "rounded",
})
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, OpenTUI!",
fg: "#00FF00",
})
box.add(text)
renderer.root.add(box)
This is verbose but gives you direct access to the instance - useful for dynamic updates and programmatic control.
Constructs
Constructs are factory functions that return VNodes (virtual nodes). They are instantiated lazily when added to the tree:
import { createCliRenderer, Box, Text, Input } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.root.add(
Box(
{ borderStyle: "rounded", padding: 1, flexDirection: "column", gap: 1 },
Text({ content: "Welcome!", fg: "#FFFF00" }),
Input({ placeholder: "Type here..." }),
),
)
Children are passed as additional arguments after the props object. VNodes support method chaining - calls are queued and replayed after instantiation:
const input = Input({ id: "my-input", placeholder: "Type here..." })
input.focus() // queued, applied when added to tree
renderer.root.add(input)
Built-in Components
| Component | Class | Construct | Purpose |
|---|---|---|---|
| Box | BoxRenderable | Box() | Container with border, background, gap |
| Text | TextRenderable | Text() | Read-only styled text |
| Input | InputRenderable | Input() | Single-line text input |
| Textarea | TextareaRenderable | - | Multi-line editable text (editor) |
| Select | SelectRenderable | Select() | Dropdown/list selection |
| TabSelect | TabSelectRenderable | TabSelect() | Horizontal tab selector |
| ScrollBox | ScrollBoxRenderable | ScrollBox() | Scrollable container |
| ScrollBar | ScrollBarRenderable | - | Standalone scrollbar |
| Slider | SliderRenderable | - | Numeric slider |
| Code | CodeRenderable | Code() | Syntax-highlighted code |
| Markdown | MarkdownRenderable | - | Markdown renderer |
| Diff | DiffRenderable | - | Unified/split diff viewer |
| ASCIIFont | ASCIIFontRenderable | ASCIIFont() | Large ASCII art text |
| FrameBuffer | FrameBufferRenderable | FrameBuffer() | Raw pixel-level drawing |
| LineNumber | LineNumberRenderable | - | Line number gutter |
| TextTable | TextTable | - | Table display |
The Renderable Tree
All renderables form a tree rooted at renderer.root (a RootRenderable). You manage the tree with:
// Add children
container.add(child) // append
container.add(child, 2) // insert at index
container.insertBefore(child, anchorChild)
// Remove children
container.remove("child-id")
// Find children
container.getRenderable("child-id") // direct child by ID
container.findDescendantById("deep-id") // recursive search
container.getChildren() // all direct children
Framework Integration
With SolidJS
Components are lowercase JSX intrinsic elements:
import { render, useKeyboard, useRenderer } from "@opentui/solid"
import { createSignal, For } from "solid-js"
const App = () => {
const [items, setItems] = createSignal(["Apple", "Banana", "Cherry"])
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "q") renderer.exit()
})
return (
<box style={{ flexDirection: "column", padding: 1, border: true }}>
<text content="Fruit List:" fg="#FFFF00" />
<For each={items()}>
{(item) => <text content={`- ${item}`} />}
</For>
</box>
)
}
render(App)
Available hooks: useRenderer(), useKeyboard(), usePaste(), onResize(), useTerminalDimensions(), onFocus(), onBlur(), useTimeline().
With React
Same lowercase JSX elements, React hooks:
import { createCliRenderer } from "@opentui/core"
import { createRoot, useKeyboard, useRenderer } from "@opentui/react"
import { useState } from "react"
const App = () => {
const [count, setCount] = useState(0)
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "space") setCount(c => c + 1)
if (key.name === "q") renderer.exit()
})
return (
<box style={{ padding: 1, border: true, flexDirection: "column" }}>
<text content={`Pressed ${count} times`} fg="#00FF00" />
<text content="SPACE to count, Q to quit" />
</box>
)
}
const renderer = await createCliRenderer({ exitOnCtrlC: true })
createRoot(renderer).render(<App />)
Available hooks: useRenderer(), useKeyboard(), useResize(), useTerminalDimensions(), useTimeline().
Custom Components (Both Frameworks)
Register custom renderables for use in JSX:
// Solid
import { extend } from "@opentui/solid"
extend({ myWidget: MyWidgetRenderable })
// Now <myWidget prop="value" /> works in Solid JSX
// React
import { extend } from "@opentui/react"
extend({ myWidget: MyWidgetRenderable })
Props and Styles
Props are set directly on renderable instances. In JSX, you can use individual props or a style object:
// Individual props
<box width={40} height={10} border={true} borderStyle="rounded" />
// Style object (spreads to individual props)
<box style={{ width: 40, height: 10, border: true, borderStyle: "rounded" }} />
Gotcha: The
styleprop is not CSS. It is a bag of properties that get set directly on the renderable instance. There is no cascade, no specificity, no inheritance.
Layout
Layout uses Yoga (the same engine React Native uses). Every renderable gets a Yoga node. The full CSS Flexbox model is available:
{
flexDirection: "column", // row | column | row-reverse | column-reverse
justifyContent: "center", // flex-start | flex-end | center | space-between | space-around | space-evenly
alignItems: "stretch", // flex-start | flex-end | center | stretch | baseline
flexGrow: 1,
flexShrink: 0,
width: 40, // number (cells), "auto", or "50%"
height: "100%",
padding: 2,
margin: 1,
gap: 1,
position: "relative", // relative | absolute
}
Events
Mouse
const button = new BoxRenderable(renderer, {
id: "btn",
border: true,
onMouseDown: (event) => { /* clicked */ },
onMouseOver: (event) => { /* hover in */ },
onMouseOut: (event) => { /* hover out */ },
onMouseScroll: (event) => { /* scroll */ },
})
Mouse events bubble up through the tree. Call event.stopPropagation() to stop bubbling.
Keyboard
Keyboard events go to the focused renderable:
const input = new InputRenderable(renderer, {
id: "input",
onKeyDown: (key) => {
if (key.name === "escape") input.blur()
},
})
input.focus() // give it keyboard focus
Visibility, Opacity, Z-Index
renderable.visible = false // removes from layout (like display: none)
renderable.opacity = 0.5 // affects renderable and all children
renderable.zIndex = 100 // higher values render on top
Lifecycle
Override these methods in custom renderables:
class Custom extends Renderable {
onUpdate(deltaTime: number) { } // called each frame
onResize(width: number, height: number) { } // dimensions changed
onRemove() { } // removed from parent
renderSelf(buffer, deltaTime) { } // custom drawing
}
Always call renderer.destroy() when your app exits to restore terminal state.