---
title: "UI tree & layout | zenit Zig UI Docs"
description: "Build zenit’s spatial model with node constructors and Flex-style layout."
url: https://ui.surf/zenit/docs/guide/ui-tree
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/ui-tree.md
alternate_es: https://ui.surf/zenit/es/docs/guide/ui-tree.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/ui-tree.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/ui-tree.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/ui-tree.md
alternate_de: https://ui.surf/zenit/de/docs/guide/ui-tree.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# UI tree & layout

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

## Mental model

Your mount function builds a real, retained tree of `*ui.Node`s, 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

| Constructor | Use |
| --- | --- |
| `ui.box` | General container with full control of direction, size, spacing and background. Defaults to column, fit width and height |
| `ui.hstack / ui.vstack` | A box with direction fixed to row / column |
| `ui.text / ui.textFmt` | Static text / formatted text that subscribes to Signals: `textFmt(cx, scope, fmt, .{signals}, props)` |
| `ui.icon / ui.iconTint / ui.svg / ui.image` | Vector icons, tinted icons, SVG and bitmap textures |
| `ui.grid` | A grid container laid out on column / row tracks |
| `ui.spacer` | Flexible blank space, grow in both axes |
| `ui.clickable` | Attaches an `on_click` handler to any node and returns it |
| `ui.boxStyled / hstackStyled / vstackStyled / textStyled` | Take a named style function and replay it on theme change. See [Styling & themes](https://ui.surf/zenit/docs/guide/styling) |

> TIP
> 
> **Reach for a widget first.** Buttons, inputs, lists and other controls in `ui.widgets` already carry state, focus and keyboard semantics; the constructors are for the structure between them. Browse the [component site](https://ui.surf/zenit/components).

## 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`

```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`

```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));
```

[Video](https://ui.surf/zenit/media/stories/stack.mp4?v=f9618b3adf)

The real [Stack](https://ui.surf/zenit/components/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:

| Shorthand | Member | Meaning |
| --- | --- | --- |
| `.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;
```

[Video](https://ui.surf/zenit/media/stories/layoutbox.mp4?v=4d3c4ba31b)

The LayoutBox story: box model, percentages, absolute positioning, Flex / Grid and clipping, each with padding-box and content-box guides.

> TIP
> 
> **Check the parent first.** When alignment or hit areas look wrong, the cause is usually the parent’s padding, gap, direction or sizing, not the leaf. During development, `ui.devtools.overlay.attach(cx, scope, root, .{})` adds a hover inspector that outlines each node’s rect (see [DevTools](https://ui.surf/zenit/docs/advanced/devtools)).

## 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`.

| Level | Typical fields | Next frame |
| --- | --- | --- |
| `.sizing` | `width, height` | Relayout self and parent |
| `.layout` | `padding, margin, gap, direction, justify, align_items, min/max_*` | Relayout this node |
| `.interaction` | `opacity, translate_*, corner_radius, border, z_index, cursor` | Update hit index and repaint |
| `.render` | `background, shadow, gradient, outline, text_color` | Regenerate paint only |
| `.none` | `tab_index, layout_isolation` | No frame work |

`update.zig`

```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.

> WARNING
> 
> **Replace text with setTextContent.** Don’t `getText()`, point `content` at a temporary string and `setText` it back: `setText` doesn’t copy content, and if the old content was owned the `owned` flag is copied along and gets confused with the new slice. `setTextContent` dupes the new content, marks it owned, and lets the framework free the previous copy. When you build `TextProps` yourself, use `props.setContent(cx.allocator, src)`: up to 16 bytes go into the inline buffer with no allocation, longer content is duped as owned. `setInlineContent` returns `error.InlineContentTooLong` past 16 bytes instead of silently truncating.

> NOTE
> 
> **Low-frequency fields need an allocator.** Fields like `shadow`, `z_index`, `corner_radius` and `min_width` live in an on-demand StyleExt. Passing a literal `null` allocator for them is a compile error; when unsure, pass `cx.allocator`.
