Constructs
Constructs let you compose your UI in a declarative, React-like way. They are factory functions that create VNodes (virtual nodes). A VNode is a lightweight description of a component. When you add a VNode to the tree, it becomes an actual Renderable.
Basic Usage
Constructs look like function calls that return component descriptions:
import { createCliRenderer, Box, Text, Input } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.root.add(
Box(
{ width: 40, height: 10, borderStyle: "rounded", padding: 1 },
Text({ content: "Welcome!" }),
Input({ placeholder: "Enter your name..." }),
),
)
Pass children as additional arguments after the props object, not as a property.
Available Constructs
Most core Renderable classes have matching construct functions:
import { Box, Text, Input, Select, TabSelect } from "@opentui/core"
// These create VNodes, not actual Renderables
const box = Box({ border: true })
const text = Text({ content: "Hello" })
const input = Input({ placeholder: "Type here..." })
How Constructs Work
When you call a construct like Box(), it creates a VNode - a plain object describing what to create:
// This creates a VNode, not an actual BoxRenderable
const myBox = Box({ width: 20, height: 10 })
// The VNode is instantiated when added to the tree
renderer.root.add(myBox) // Now it becomes a real BoxRenderable
This deferred creation lets you:
- Compose UI without a render context
- Queue method calls before the component exists
- Write cleaner, more declarative code
Creating Custom Constructs
Create reusable components by writing functions that return VNodes:
function LabeledInput(props: { label: string; placeholder: string }) {
return Box(
{ flexDirection: "row", gap: 1 },
Text({ content: props.label }),
Input({ placeholder: props.placeholder, width: 20 }),
)
}
renderer.root.add(
Box(
{ flexDirection: "column", padding: 1 },
LabeledInput({ label: "Name:", placeholder: "Enter name..." }),
LabeledInput({ label: "Email:", placeholder: "Enter email..." }),
),
)
Method Chaining on VNodes
VNodes support method calls. The system queues these calls and applies them after creating the component:
const input = Input({ id: "my-input", placeholder: "Type here..." })
// The VNode queues this call
input.focus()
// When added, the system creates the input and calls focus()
renderer.root.add(input)
You can also set properties:
const box = Box({ id: "my-box" })
box.backgroundColor = RGBA.fromHex("#333366")
renderer.root.add(box)
The delegate() Function
Composite components often need outer method calls to reach a specific inner component. The delegate() function maps method and property names to descendant IDs:
import { delegate, Box, Text, Input } from "@opentui/core"
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
return delegate(
{
focus: `${props.id}-input`, // Route focus() to the input
value: `${props.id}-input`, // Route value property access
},
Box(
{ flexDirection: "row" },
Text({ content: props.label }),
Input({
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}),
),
)
}
const username = LabeledInput({ id: "username", label: "Username:", placeholder: "Enter username..." })
// This actually focuses the nested Input, not the outer Box
username.focus()
renderer.root.add(username)
Delegate Mappings
The mapping object’s keys are method or property names, and the values are descendant IDs:
delegate(
{
focus: "inner-input", // .focus() -> find descendant "inner-input" and call focus()
blur: "inner-input", // .blur() -> same
add: "content-area", // .add() -> add children to "content-area" instead
value: "inner-input", // .value get/set -> proxy to "inner-input"
},
vnode,
)
Composing with Children
Custom constructs can accept and pass through children:
function Card(props: { title: string }, ...children: VChild[]) {
return Box(
{ border: true, padding: 1, flexDirection: "column" },
Text({ content: props.title, fg: "#FFFF00" }),
Box({ flexDirection: "column" }, ...children),
)
}
renderer.root.add(Card({ title: "User Info" }, Text({ content: "Name: Alice" }), Text({ content: "Role: Admin" })))
Mixing Renderables and Constructs
You can mix both approaches:
import { BoxRenderable, Text, Input } from "@opentui/core"
// Create a renderable directly
const container = new BoxRenderable(renderer, {
id: "container",
flexDirection: "column",
})
// Add constructs to it
container.add(Text({ content: "Title" }), Input({ placeholder: "Type here..." }))
renderer.root.add(container)
Or use constructs that contain renderables:
const customRenderable = new CustomRenderable(renderer, { id: "custom" })
renderer.root.add(
Box(
{ padding: 1 },
Text({ content: "Header" }),
customRenderable, // Regular renderable mixed in
Text({ content: "Footer" }),
),
)
Next Steps
See Renderables vs Constructs for a detailed comparison of when to use each approach.