docs/guide/ui-tree
Core concepts · Nodes

UI tree & layout

Build zenit’s spatial model with node constructors and Flex-style layout.

10 min read

Mental model

Your mount function builds a real, retained tree of *ui.Nodes, once. Containers decide how children are arranged; leaves carry text, images or icons. On every later frame the framework re-enters the pipeline only from dirty nodes: layout, paint into display items, hand off to Metal.

FRAME PIPELINE
Every style field has a dirty level. A background change only regenerates paint; a width change re-lays out the parent too. With a clean tree and no animation running, the whole GPU submission is skipped.

Constructors

ConstructorUse
ui.boxGeneral container with full control of direction, size, spacing and background. Defaults to column, fit width and height
ui.hstack / ui.vstackA box with direction fixed to row / column
ui.text / ui.textFmtStatic text / formatted text that subscribes to Signals: textFmt(cx, scope, fmt, .{signals}, props)
ui.icon / ui.iconTint / ui.svg / ui.imageVector icons, tinted icons, SVG and bitmap textures
ui.gridA grid container laid out on column / row tracks
ui.spacerFlexible blank space, grow in both axes
ui.clickableAttaches an on_click handler to any node and returns it
ui.boxStyled / hstackStyled / vstackStyled / textStyledTake a named style function and replay it on theme change. See Styling & themes

A layout example

Create the container, then attach children with appendChild. Style values come from cx.tokens first, so theme switches and spacing changes don’t mean hunting down literals.

card.zig
const card = try ui.vstack(cx, .{
    .width = .fixed(360),
    .gap = cx.tokens.space._4,
    .padding = ui.Padding.all(cx.tokens.space._6),
    .background = cx.tokens.color.bg_secondary,
    .corner_radius = cx.tokens.radius.xl,
    .align_items = .stretch,
}, .{});

const header = try ui.hstack(cx, .{
    .gap = cx.tokens.space._2,
    .align_items = .center,
}, .{});

try header.appendChild(cx.allocator, try ui.iconTint(
    cx,
    ui.icons.star,
    cx.tokens.color.accent,
    .{ .width = .fixed(18), .height = .fixed(18) },
));
try header.appendChild(cx.allocator, try ui.text(cx, "Overview", .{}));
try header.appendChild(cx.allocator, try ui.spacer(cx));
try card.appendChild(cx.allocator, header);

Children can also be passed as a tuple at construction; ui.Padding.symmetric(v, h) sets vertical and horizontal padding separately.

row.zig
// Children can also be passed as a tuple at construction time.
const row = try ui.hstack(cx, .{
    .width = .fill(),
    .padding = ui.Padding.symmetric(cx.tokens.space._2, cx.tokens.space._4),
    .gap = cx.tokens.space._3,
}, .{
    try ui.text(cx, "Name", .{}),
    try ui.spacer(cx),
    try ui.text(cx, "42", .{ .color = cx.tokens.color.fg_secondary }),
});

// Any node can take a click handler.
// (model: *Model from cx.bindState; Model.select is fn (*Model) void)
_ = ui.clickable(row, cx.on(Model, model, Model.select));
The real Stack story: horizontal, vertical and nested containers all resolve through the same rect, gap, align and sizing rules.

Sizing and coordinates

Width and height are a ui.Sizing union; each of its four members has a shorthand:

ShorthandMemberMeaning
.fixed(120).{ .px = 120 }Fixed pixels
.fill().{ .grow = .{} }Takes the remaining space; grow carries a min / max payload, e.g. .{ .grow = .{ .min = 200 } }
.pct(50).{ .percent = 50 }Percent (0–100) of the parent’s content box, padding excluded
—.{ .fit = .{} }Sized by content; the box default, also with min / max
SIZING
As the parent resizes, .fixed doesn’t move, .pct tracks the content box proportionally, and .fill takes whatever is left after fixed sizes, percentages and gaps.

Layout results aren’t stored as a node field. node.rectFromWorldOrFallback() returns the rect from the last layout pass, relative to the parent’s top-left; for window coordinates use node.globalRect(), which accumulates positions, translate and sticky offsets up the ancestor chain.

zig
// Parent-relative rect from the last layout pass.
const local = node.rectFromWorldOrFallback();

// Window coordinates: accumulates ancestors, translate and sticky offsets.
const screen = node.globalRect();
std.log.info("{d}x{d} at ({d}, {d})", .{ screen.w, screen.h, screen.x, screen.y });
_ = local;
The LayoutBox story: box model, percentages, absolute positioning, Flex / Grid and clipping, each with padding-box and content-box guides.

Updating nodes

When you change a node directly, the framework has to know which part of the pipeline to re-run. Prefer setStyle: it picks the dirty level from the field at compile time, so there’s no manual markLayoutDirty / markRenderDirty.

LevelTypical fieldsNext frame
.sizingwidth, heightRelayout self and parent
.layoutpadding, margin, gap, direction, justify, align_items, min/max_*Relayout this node
.interactionopacity, translate_*, corner_radius, border, z_index, cursorUpdate hit index and repaint
.renderbackground, shadow, gradient, outline, text_colorRegenerate paint only
.nonetab_index, layout_isolationNo frame work
update.zig
// setStyle picks the dirty level from the field at compile time.
node.setStyle(cx.allocator, .width, .fixed(240)); // sizing
node.setStyle(cx.allocator, .gap, 12);             // layout
node.setStyle(cx.allocator, .background, next);    // render

// Low-frequency fields live in StyleExt and need a real allocator.
node.setStyle(cx.allocator, .z_index, 10);

// Replace text: dupes the content, frees the previous owned copy,
// and re-measures only if the content actually changed.
try node.setTextContent(cx.allocator, "Updated");

// Hide without unmounting: display:none leaves layout, paint,
// hit-testing, Tab order and the a11y tree; state stays alive.
panel.setDisplay(.none);
panel.setDisplay(.flex); // back, same nodes

// Recolor an icon node whether it is icon-table or image (svgTint) backed.
_ = icon.setTint(cx.tokens.color.accent); // false: node has neither

setText compares the old and new text signatures: sizing-dirty if measurement changes, render-dirty if only appearance does, nothing at all if identical. No markRenderDirty needed after updating text.

To hide a subtree for a while, call node.setDisplay(.none) (or set .display = .none in a BoxStyle): the node and its subtree take no space, count no gap and don’t paint, and they leave hit-testing, Tab traversal and the accessibility tree. Nodes and state stay alive, and .flex brings them back, with no unmount and rebuild. setDisplay marks the node’s and its parent’s layout dirty itself. Opacity 0 alone also stops hit-testing, but the node still takes up space and stays in the Tab order.

zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01