These docs describe the main branch, including unreleased changes. Read the 0.8.0 docs.

Layout

OpenTUI uses Yoga to compute a renderable tree on a grid of terminal cells. It supports a defined set of Flexbox-like properties, not all browser CSS.

A horizontal size is a count of terminal columns. A vertical size is a count of terminal rows. These values are never character counts.

Spacing and growth

OpenTUI preview

Run this example to open an interactive terminal.

Source code · typescript
import { BoxRenderable, RGBA, TextRenderable, createCliRenderer } from "@opentui/core"

const renderer = await createCliRenderer()
const foreground = RGBA.defaultForeground()
const fill = RGBA.fromIndex(238)

const row = new BoxRenderable(renderer, {
  width: 34,
  height: 5,
  border: true,
  borderColor: foreground,
  padding: 1,
  flexDirection: "row",
  gap: 2,
})

const fixed = new BoxRenderable(renderer, { width: 8, height: 1, backgroundColor: fill })
fixed.add(new TextRenderable(renderer, { content: "fixed 8", fg: foreground }))

const growing = new BoxRenderable(renderer, {
  flexGrow: 1,
  flexBasis: 0,
  height: 1,
  backgroundColor: fill,
})
growing.add(new TextRenderable(renderer, { content: "grow 20", fg: foreground }))

row.add(fixed)
row.add(growing)
renderer.root.add(row)

The shaded children occupy 8 and 20 columns. The border and padding use four columns. The gap uses two more.

┌────────────────────────────────┐
│                                │
│ fixed 8   grow 20              │
│                                │
└────────────────────────────────┘

The same layout properties work across the built-in renderable classes.

Supported options

RenderableOptions supplies the shared renderable layout values. BoxOptions adds the three gap values.

GroupOptionsAccepted valuesDefault
Positionposition"relative" or "absolute""relative"
Edgestop, right, bottom, leftnumber, "auto", or percentageunset
Dimensionswidth, heightnumber, "auto", or percentage"auto"
LimitsminWidth, minHeight, maxWidth, maxHeightnumber or percentageunset
Marginmargin, marginX, marginY, marginTop, marginRight, marginBottom, marginLeftnumber, "auto", or percentage0
Paddingpadding, paddingX, paddingY, paddingTop, paddingRight, paddingBottom, paddingLeftnumber or percentage0
Flex sizeflexGrownumber0
Flex sizeflexShrinknumber0 for an initial numeric dimension, otherwise 1
Flex sizeflexBasisnumber or "auto""auto"
FlowflexDirection"column", "column-reverse", "row", or "row-reverse""column"
FlowflexWrap"no-wrap", "wrap", or "wrap-reverse""no-wrap"
AlignmentalignItemsYoga align value"stretch"
AlignmentalignSelfYoga align value"auto"
AlignmentjustifyContentstart, end, center, or space distribution"flex-start"
Gaps on Boxgap, rowGap, columnGapnumber or percentage0
Clippingoverflow"visible", "hidden", or "scroll""visible"

Yoga align values are "auto", "flex-start", "center", "flex-end", "stretch", "baseline", "space-between", "space-around", and "space-evenly".

justifyContent accepts the same start, center, end, and three space-distribution values. It does not accept "auto", "stretch", or "baseline".

PositionTypeString also includes "static", but renderable option validation does not implement it. Use "relative" or "absolute".

The public TypeScript interface includes "auto" for min and max dimensions. The current runtime ignores that value for those four options.

A numeric value, translateX, and translateY must be finite and at most about 3.4e38 in magnitude, the 32-bit float range. Other values throw RangeError. Drawing clamps geometry to the signed 32-bit cell range, so a very large value moves a renderable off the screen instead of failing the frame. x, y, width, and height still report the unclamped layout.

Margin, padding, edge (top, right, bottom, left), and gap values beyond ±1e30 are stored as ±1e30, and a resolved percentage is limited the same way, so layout sums stay finite. getMargin(), getPadding(), and getGap() read back the stored value.

Alignment

In a row, justifyContent distributes horizontal space and alignItems positions children vertically. A child can override the vertical alignment with alignSelf.

const row = new BoxRenderable(renderer, {
  width: 32,
  height: 7,
  border: true,
  borderColor: foreground,
  paddingX: 1,
  flexDirection: "row",
  justifyContent: "space-between",
  alignItems: "center",
})

for (const [label, height] of [
  ["A", 1],
  ["B", 3],
  ["C", 1],
] as const) {
  const child = new BoxRenderable(renderer, {
    width: 6,
    height,
    alignSelf: label === "C" ? "flex-end" : "auto",
    backgroundColor: fill,
  })
  child.add(new TextRenderable(renderer, { content: label, fg: foreground }))
  row.add(child)
}
renderer.root.add(row)

A and B share a vertical center despite their different heights. C sits at the bottom.

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

Yoga alignment positions child boxes. It does not align characters inside a text renderable. Use textAlign for left, center, or right alignment of each rendered line inside the text width.

Automatic and intrinsic size

An "auto" dimension lets Yoga derive size from children or from a renderable's measure function. Text and editor renderables measure their content in native code.

Yoga calls a measure function with exact, at-most, or unconstrained dimensions. Text measurement uses display-cell width and the active wrap mode.

wrapMode: "word" and "char" can increase measured height under a width constraint. wrapMode: "none" keeps the intrinsic line width.

textAlign does not change measured size. It is a draw offset inside the computed width.

A percentage dimension resolves against the parent's corresponding size. If that parent lacks a definite size, the result can differ from a browser layout assumption.

Min and max dimensions constrain the result. Padding and borders consume space inside the computed box.

Relative and absolute flow

Relative children take part in flex layout. Their edge values offset the Yoga position.

Absolute children do not consume space in normal flex flow. Their edges position them against the containing layout box.

Absolute text also uses its intrinsic measure without the relative child's at-most clamp. Set a width when you need predictable wrapping.

Use absolute positioning for overlays and fixed geometry. Use flex flow for ordinary page structure.

Cell rounding

Yoga uses a point scale factor of 1. It rounds computed edges to whole terminal cells while preserving the layout total.

Percentage and flex calculations can produce fractions before this step. Adjacent children can therefore receive different rounded widths.

These three children use the same grow factor and zero basis:

const child = new BoxRenderable(renderer, {
  flexGrow: 1,
  flexBasis: 0,
  minWidth: 0,
})

A 32-column bordered row leaves 30 inner columns. Adding one column gives the middle child an extra cell, without gaps or overlaps:

32 columns: 30 inside
┌──────────────────────────────┐
│    10        10        10    │
└──────────────────────────────┘
33 columns: 31 inside
┌───────────────────────────────┐
│    10         11        10    │
└───────────────────────────────┘

Renderable getters expose computed integer geometry after a layout pass. OpenTUI clamps exposed width and height to at least one cell.

Paint does not clamp. A node that Yoga sizes to zero width or height draws no cells and receives no mouse events, although its getters report one cell. Its children still paint unless the node clips them. For example, when a column is too short for its children, flexShrink can reduce a text to zero rows, and that text disappears. Give the text flexShrink: 0 to keep it visible.

Read style and geometry

x, y, width, height, and getLayout() report computed geometry from the last layout pass.

To read the style that was set, call the method with the name of the matching Yoga Node getter. It returns the same Value or enum type. A change is visible at once, before the next layout pass:

box.maxWidth = 40
box.getMaxWidth() // { unit: Unit.Point, value: 40 }
box.width // the computed width from the last layout pass
OptionsGetters
width, heightgetWidth(), getHeight()
minWidth, minHeight, maxWidth, maxHeightgetMinWidth(), getMinHeight(), getMaxWidth(), getMaxHeight()
Margin and padding optionsgetMargin(edge), getPadding(edge)
gap, rowGap, columnGapgetGap(gutter)
flexGrow, flexShrink, flexBasisgetFlexGrow(), getFlexShrink(), getFlexBasis()
flexDirection, flexWrapgetFlexDirection(), getFlexWrap()
alignItems, alignSelf, justifyContentgetAlignItems(), getAlignSelf(), getJustifyContent()
positiongetPositionType()

An edge or gutter getter reads only the value set for that edge or gutter. padding sets Edge.All, paddingX sets Edge.Horizontal, paddingY sets Edge.Vertical, and paddingLeft sets Edge.Left. The margin and gap options work the same way. After padding: 2, getPadding(Edge.Left) returns a Unit.Undefined value, but layout still pads the left side by 2, because Yoga falls back from Edge.Left to Edge.Horizontal and then to Edge.All. Renderables have no getter for computed padding, margin, or border.

top, right, bottom, left, and overflow have property getters. Assign an option to change its style.

Assigning null or undefined to a layout option restores the default in Supported options. A class that sizes itself, such as InputRenderable or TabSelectRenderable, restores its own width or height. Other class defaults, such as the row direction of ScrollBoxRenderable, are not restored, and flexShrink becomes 1. A margin, padding, or gap edge becomes unset, so layout falls back to its shorthand again. Assigning 0 sets the edge instead:

const box = new BoxRenderable(renderer, { paddingX: 2 })
box.getPadding(Edge.Left) // { unit: Unit.Undefined, value: NaN }
box.paddingLeft = 5 // pads the left side by 5
box.paddingLeft = undefined // pads it by 2 again, from paddingX

To write back a Value that a getter returned, assign undefined for Unit.Undefined, "auto" for Unit.Auto, `${value}%` for Unit.Percent, and value for Unit.Point. Assigning a number to width or height also changes a flexShrink of 1 to 0, so restore getFlexShrink() with them.

Resize behavior

A terminal resize keeps the same renderable instances. The renderer resizes its root and runs Yoga again with the new column and row counts.

With flexWrap: "wrap", whole children move to the next row when they no longer fit. The container's automatic height grows to include them:

const row = new BoxRenderable(renderer, {
  width: "100%",
  border: true,
  borderColor: foreground,
  paddingX: 1,
  flexDirection: "row",
  flexWrap: "wrap",
  alignItems: "flex-start",
  columnGap: 2,
  rowGap: 1,
})

for (const content of ["A", "B", "C"]) {
  const child = new BoxRenderable(renderer, {
    width: 8,
    height: 1,
    flexShrink: 0,
    backgroundColor: fill,
  })
  child.add(new TextRenderable(renderer, { content, fg: foreground }))
  row.add(child)
}
renderer.root.add(row)

The same children before and after narrowing the terminal:

32 columns
┌──────────────────────────────┐
│ A         B         C        │
└──────────────────────────────┘
22 columns
┌────────────────────┐
│ A         B        │
│                    │
│ C                  │
└────────────────────┘

Computed x, y, width, and height can all change. When a visible renderable's size changes, OpenTUI first resizes its frame buffer if it is buffered. Then it calls onResize(width, height).

FrameBufferRenderable resizes its public frameBuffer in its onResize() override, then calls the base hook.

The base onResize() calls onSizeChange and emits the renderable's "resize" event. After onResize() returns, OpenTUI requests another render.

Renderer resize listeners receive the render-region width and height.

Custom streams must call renderer.resize(columns, rows). Local process.stdout sessions use SIGWINCH automatically.

Common failures

  • A numeric initial width or height sets the default flexShrink to 0. Set flexShrink when the node must contract.

  • An absolute child does not increase its parent's automatic size.

  • A percentage needs a useful parent size.

  • overflow: "scroll" clips like a Yoga overflow mode, but it does not add scroll state. Use ScrollBox.

  • Gap options belong to Box, not every renderable class.

  • visible = false removes a renderable from layout.

  • A scene holds at most 256 levels, counting the root. An add() or insertBefore() that nests deeper throws NativeError with status ObjectLimit.

  • Text can contain fewer graphemes than cells or more UTF-16 units than cells. See Text and terminal cells.

Next

Read Renderables for tree mutation. Use the advanced Yoga API only when the shared options do not cover your case.

Changes0.7.0
0.7.0
core: translateX and translateY throw RangeError for values outside the 32-bit float range of the layout setters. Before, such values did not paint. (#1682)
core: A Yoga measure of text at the render width reads the render layout, and at a different width it measures again only the edited lines. One edit in a 10,000-line word-wrapped editor measures 0 lines, not 10,000. (#1772)
core: After style and paint writes, reads of x, y, width, and height use the cached layout without a native call or a flush. getLayout() and getComputedLayout() call native code on every call, and a rejected staged write shows at the next flushing call. (#1760)
core: Text with a fractional flex size rounds like a box and stays inside the rounded box of its parent. Before, Yoga rounded the right edge of text up but rounded containers to the nearest cell, so the last glyph of each wrapped line could draw under the next column or under a ScrollBox scrollbar. Some layouts move by one cell. Text at a half-cell offset, such as centered text with an odd leftover, moves right or down to the position of a box of the same size. A box centered inside another centered box, with odd leftovers at both levels, can move left or up. Update snapshots of such layouts. The public yoga.ts API keeps the upstream rounding. (#1728)
core: A node with layout outside 32-bit cell coordinates, such as left = 2 ** 31 - 1 or margin = 1e15, no longer makes every later frame fail with InvalidArgument. Native code clamps only the geometry that it paints, clips, and hit-tests. (#1682)