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.