---
title: "The UI window system"
description: "UI in Zero is declarative and immediate-mode: you describe the interface as a tree of widget tables and register it as a named screen, and the engine renders that description every frame. You never…"
section: "Topics"
slug: "topics-ui"
canonical: "https://origozero.ai/docs/topics-ui"
updated: "2026-09-05T16:41:46.836850542+00:00"
tags: ["documentation", "guide"]
---

# The UI window system

Why immediate-mode: there is no retained widget graph to fall out of sync with
your state, no create/destroy/diff to manage. The tree *is* a pure function of
your state; render it, and what you see is exactly what you declared. The cost
is that geometry exists only after a render pass — see "The runtime model".

## A screen is a widget tree

A node is a table with a `type`, a `props` table (its data and behaviour), a
`style` table (its looks and layout), and `children`:

```lua
ui.registerScreen("hud", {
  type = "vertical",
  style = { gap = 8, padding = 16, background = "#0e1116" },
  children = {
    { type = "label",  props = { text = "Health 100" }, style = { fontSize = 28, color = "#e6e6e6" } },
    { type = "button", props = { text = "Inventory", onClick = "open_inv" } },
  },
}, 10)                 -- optional layer: a higher number draws on top

ui.showScreen("hud")
ui.updateScreen("hud", buildTree())   -- swap the whole tree to re-render
ui.hideScreen("hud")
```

- **`props`** carries the widget's data and behaviour: a label's `text`, a
  slider's `value` / `min` / `max`, an interactive widget's callback ids
  (`onClick`, `onChange`), `visible`, `wrap`.
- **`style`** carries everything visual and geometric — the whole CSS surface in
  the [styling guide](styling). A visual key placed in `props` (or a data key in
  `style`) does nothing and logs a warning; keep the two straight.
- **`children`** nests the tree; `horizontal` and `vertical` are the flex row and
  column that lay them out.
- **`id`** — a top-level key on any node — names that element. It is the name
  every addressing surface answers to: `ui.getLayoutInfo(id)`, `gui bounds`,
  `gui clickElement` / `hoverElement` / `dragElement`, `gui captureElement`, and
  the node's entry in `gui elementTree`. Without one the engine generates
  `<screen>/<type>@<path>`, which addresses the node just as well but moves when
  the tree's shape changes — so name the nodes you intend to drive or capture.

### Stacking — what `layer` decides

`layer` sorts every screen against every other one, whatever their root widgets
are, and it resolves in bands: below `0` sits behind everything, `0`–`99` is the
ordinary app depth, `100`–`999` is always-on-top chrome (a taskbar, a persistent
overlay), and `1000`+ is menu and popup depth. A screen in a higher band covers
a screen in a lower one, so a card at `layer = 800` composites over a radar
panel at `layer = 20` whether either is rooted in a `vertical`, a docked
`topPanel`, an `area` or a `window`.

Inside one band the root type finishes the order. A floating root — an `area`, a
`window`, and the anchored overlays a screen builds — floats over the ordinary
content of its own band, which is what makes a window sit on the page it belongs
to. Ordinary content in one band draws in `layer` order; floating roots in one
band order by interaction, where clicking one raises it and
`ui.bringAreaToFront(id)` does the same from code.

A `modal` root takes the top of the stack and dims what it covers whatever its
screen's `layer` says — that is what makes it modal.

**Do not memorise the widget set — query it.** `gui widgetTypes` lists every
`type`; `gui widgetProps <type>` lists a type's `props` with descriptions. There
are containers (`vertical`, `horizontal`, `grid`, `scrollArea`, the screen-edge
panels), text and media leaves (`label`, `svg`, `image`), interactive widgets
(`button`, `slider`, `checkbox`, `input`), and windowing types (`area`, `modal`,
`popup`). Read a working screen from `@builtin::examples.ui.*` before building
from nothing — a dashboard, a sci-fi HUD, an RPG inventory and more, each a
complete CSS-parity screen showing the real structure and style in use. Load
them all live with `layers.load("@builtin::examples.ui.gallery")`.

## Interactivity — a callback needs an owner

An interactive widget carries a **string callback id**, and `onClick` works on
**any** widget, not just a `button` — put `onClick = "id"` on an `image` (a
clickable icon), a `vertical` / `horizontal` (a whole clickable row or card), or
a `label` (a menu item). The engine senses the click over that widget's box; you
never overlay a transparent button.

When the user acts, the engine delivers that id to a top-level function named
**`onCallback(callbackId, data)`** — and *where* that function lives is the part
that trips people up. Callbacks reach a persistent owner:

- a **component** — `onCallback` top-level in the component module, beside
  `awake` / `update`, or
- a **scene entrypoint** — `onCallback` top-level in the scene script.

A bare `execute`/REPL call that registers a screen is **not** a persistent
environment, so its clicks go nowhere. This is the rule to internalise: **a
standalone interactive UI is authored as a component** (or a scene entrypoint),
never as a loose `registerScreen` from the REPL. Register the screens in the
component's `awake`/`update` and handle every id in its `onCallback`. (`gui
click <id>` fires one by hand for testing.)

```lua
local function buildTree() ... end                    -- tree = f(state)

function awake()          ui.registerScreen("app", buildTree()) end
function update(dt)       ui.updateScreen("app", buildTree()) end   -- on state change
function editorUpdate(dt) if engine.paused then update(dt) end end  -- tick while the clock is held
function onCallback(id, data)                          -- every onClick/onChange lands here
  if id == "open_inv" then state.invOpen = true end
end
```

Interaction — like all gameplay — runs in **play**, not edit. Wire a handler,
then enter play (`wld play`) and actually use it: a handler that is never
invoked looks identical to a correct one until you drive it. Hover and press are
styled without a callback (see interaction states in the styling guide); `gui
state <id>` reads a node's live hover/press/focus snapshot.

## Building a full-screen app

Anything that owns the viewport — a HUD, a menu, a dashboard — is a
**component**: its lifecycle functions are top-level (no `self`), and
module-level locals hold its state. Two presentation facts that are discoverable
but not obvious:

- **Give the root an opaque background.** Containers are transparent by default
  (like a CSS element with no `background`), so an unpainted root shows the scene
  through it. Set an opaque `background` on the root, or use a `centralPanel`
  root, which fills the viewport opaquely.
- **Drive live content from `update` / `editorUpdate`.** A clock or a data
  readout recomputes its label and calls `updateScreen`. Put per-frame content in
  its *own* small screen and refresh only that — re-registering a whole screen
  every frame (especially a window whose position the user can drag) is wasted
  work and easy to get subtly wrong.

## Windows — draggable, resizable areas

A floating window is its own screen whose **root is an `area`**. The area's
keys — `id`, `pos`, `movable`, `resizable`, `pivot`, `interactable` — sit at the
**top level** of the node, not inside `props`. Give it an explicit `id`:
`ui.getAreaPos(id)` / `ui.setAreaPos(id, x, y)` and `ui.getAreaSize(id)` /
`ui.setAreaSize(id, w, h)` / `ui.resetAreaSize(id)` key off it, and without one a
dragged position or size is never recorded.

The declared `pos`/`width`/`height` are re-seat controls: **changing** the value
moves or resizes the window there, while **re-registering with the same** value
leaves a window the user dragged or resized exactly where they left it — so a
normal per-frame re-render never yanks a window around. A docked `bottomPanel` /
`topPanel` (a taskbar) always draws under a floating window; a maximised window
fills the *work area* (screen minus the taskbar), so it never fights the
taskbar's z-order.

The screen-edge panels (`topPanel`, `bottomPanel`, `leftPanel`, `rightPanel`,
`centralPanel`) are **root-only**: the renderer docks them from the screen root,
so each must be a screen's root node — nested inside a `vertical`/`horizontal`
it renders as `[<kind> '<id>' must be screen root, not nested]`. To build a
toolbar-body-status shell, use a
full-height `vertical` of styled rows (a header row, a `flex = 1` body, a footer
row) rather than nested panels; reserve the edge panels for their own taskbar
screens.

## The runtime model — what to know before you are surprised

- **One coordinate space: `ui.screenSize()`.** Every geometric value — an area's
  `pos`, a widget's `width`/`height`, `ui.getLayoutInfo` rects, anchors — lives
  in one logical canvas whose size is `ui.screenSize()` (which can be large,
  e.g. thousands of units). **Never measure off a capture image**: a capture is a
  *downscaled* copy of that canvas, so a position read from the screenshot's
  pixels is wrong by the downscale factor. To place things, compute against
  `ui.screenSize()`; to check where something landed, read `ui.getLayoutInfo(id)`
  (canvas space) — or `gui bounds <id>`, `gui elementTree`.
- **A screen is observable the *next* frame, not the same call.**
  `registerScreen` / `updateScreen` queue the change; it applies at the frame
  boundary, and a screen's layout only exists after a render pass. So
  `ui.getLayoutInfo(id)` / `gui elementTree` called in the *same* `execute` that
  registered the screen read the last published frame and do not see it yet.
  Register in one step, query in a later one — a `capture` or a follow-up call is
  already frames later. This is the immediate-mode model, not a bug.
- **A blank screen is a positive signal.** A registered screen that renders
  nothing is transparent (no `background`), sized to zero, off-canvas, or failed
  validation — not merely "not shown". Check `gui validate` and capture
  `source = "screen"`.

## Why is my widget not showing, and what did my click hit

`ui.getLayoutInfo` and `gui elementTree` answer where layout put a widget.
`ui.observe()` and `ui.diagnose(id)` answer what the paint stage did with it.

```lua
ui.diagnose("hud-healthbar")
-- { reason = "occluded", detail = "'hud-backdrop' paints later and fills the
--   whole box at full alpha", occludedBy = "hud-backdrop",
--   rect = {...}, clip = {...}, visible = {...}, paintIndex = 41, ... }
```

`reason` comes from a closed set — `ui.invisibilityReasons()` lists it:

| reason | what the engine found |
|---|---|
| `painted` | it reached the frame |
| `noSuchWidget` | no registered screen's element tree carries the id |
| `hiddenByAuthor` | `props.visible = false` on it or an ancestor kept it out |
| `screenNotShown` | its screen is registered and not shown |
| `neverLaidOut` | its screen is shown and the frame laid out no box |
| `zeroSize` | its own box has no area |
| `offViewport` | its box sits entirely outside the viewport |
| `clippedByAncestor` | an ancestor's clip removed the whole box |
| `occluded` | a later widget covered it with an opaque fill |
| `screenNotOnViewport` | its box is fine and the camera holding the viewport excludes its screen's render layer, so the frame measured it without drawing it |

Nearest cause wins: a widget both clipped away and covered reports the clip.

Three fields carry the geometry the layout rect alone cannot: `clip` is the
clip chain the widget painted under, `visible` is the part of its box that
survived that clip and the viewport, and `paintIndex` is the order it painted
in — which is what separates two widgets whose rects are identical
(`ui.paintOrder(a, b)` compares two directly).

For the pointer: `ui.hitTest(x, y)` names the widget a point belongs to and the
stack under it, in the space `ui.screenSize()` reports — the same space
`gui clickAt` takes. Where boxes overlap, the one painting last takes the point
and the containers holding it follow, so the answer is the widget a click there
lands on rather than the smallest box around the coordinate.
`ui.pointerWidget()` is the pointer counterpart of
`ui.focusedWidget()`: where the pointer sits, whether the UI claimed it, and
which widget the frame reported it over.

The whole reading is `ui.observe()`, one row per widget, and
`/zero/runtime/ui/<screen>/paint.json` serves the same rows to a non-Luau
reader. `generation` advances once per re-rendered frame, so two reads
reporting the same number describe the same frame.

## Seeing and driving it

You judge a UI by its pixels and its behaviour, not by its tree. The `gui`
toolbox is the whole operational surface: `capture source="screen"` (and
`captureElement` for one element or a composite) to see it; `elementTree` /
`bounds` / `validate` / `state` to read it; `clickElement` / `clickAt` /
`hoverElement` / `dragElement` / `key` to drive it, and `scrollElement <id>
<offsetY>` to scroll a specific `scrollArea` to an offset; `show` / `hide` /
`toggle` / `setTheme` to manage it. `wld play` / `wld edit` flip modes.

## UI in the 3D world

For world-space UI — a nameplate, a floating health bar — add a `UiPanel`
component to an entity with a tree:

```lua
entity(id).component.add("UiPanel", { mode = "3d", tree = { type = "label", props = { text = "NPC" } } })
```

## When a script errors

An uncaught error surfaces as a top-centre `__task_error_overlay` screen so a
failure is visible rather than a silent freeze, and it is logged — the `logs`
toolbox's **errors** tool has the full stack. In play the overlay auto-clears;
in edit (paused) dismiss it
with `ui.unregisterScreen("__task_error_overlay")`.
