Log inGet started

UI

UI in Zero is data, not drawing. You describe what you want as a tree of widget tables, register it as a named screen, and the engine renders it every frame. You never paint pixels — you declare…

Each widget node carries two tables: props holds its data and behavior (text, value, the onClick id) and a sibling style holds its looks and layout (fontSize, color, background, padding, width). Visual keys go in style — the styling guide (guides { path: "topics/ui/styling" }) is the canonical reference for the full set.

A screen is a widget tree

ui.registerScreen("hud", {
  type = "vertical",
  style = { gap = 8, padding = 16 },        -- layout/visuals live in `style`
  children = {
    -- props = data; style = visuals
    { type = "label",  props = { text = "Health: 100" }, style = { fontSize = 28, color = "#e6e6e6" } },
    { type = "button", props = { text = "Inventory", onClick = "open_inv" } },
  },
}, 10)                          -- optional layer; higher draws on top

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

Each node has a type, a props table (the widget's data — text, value, the onClick id), an optional sibling style table (its visuals and layout — fontSize, color, background, padding, margin, borderRadius, gap, align, justify, width, height, position/left/top), and optional children. The full set of style keys is the ZuiStyle schema; a style key placed in props does nothing (and logs a style-key-in-props warning). The styling guide (guides { path: "topics/ui/styling" }) is the canonical reference for the props-vs-style split and every key. Updating UI means handing updateScreen a fresh tree — there's no retained widget object to mutate.

Don't memorise the widget set — ask for it

There are many widget types (labels, buttons, inputs, sliders, lists, panels, tabs, tables, graphs, game widgets like health bars and hotbars, and layout containers). Rather than guessing, query the live set:

ui.getWidgetTypes()             -- every widget type
ui.getWidgetProps("slider")     -- the props one widget takes

ui.getWidgetProps(type) lists a widget node's props — the keys inside its raw props table — not the positional arguments of the matching Z.* builder. Those are different layers: the builder's call shape (its argument order) is documented in the builder's own module and summarised under Two ways to write the tree below.

ui.getWidgetProps("input") is worth a look before sizing an input: a multiline input sizes by its desiredRows / desiredWidth props, not a style height. To probe whether some global exists, index _Gtype(_G.someName) — rather than referencing a bare name (someName), which the strict analyzer treats as an unknown global and refuses the whole snippet over (the error lists how to soften the gate, including strict = false).

Callbacks — interactivity needs an onCallback owner

An interactive widget carries a string id (onClick, onChange, …). When the user clicks it, the engine delivers that id to every live script environment that defines a function named onCallback:

{ type = "button", props = { text = "Save", onClick = "save_clicked" } }
-- in the SAME script environment that registered the screen:
function onCallback(callbackId, data)
    if callbackId == "save_clicked" then save() end
end

The delivery target is the part that trips people up: a bare execute / REPL call that registers a screen is not a persistent environment, so its clicks go nowhere. Callbacks reach two kinds of owner:

  • a component — a top-level function onCallback(callbackId, data) in the component module (alongside awake / update), or
  • a scene entrypoint — a top-level function onCallback(callbackId, data) in a scene script.

So a standalone interactive UI is authored as a component (or a scene entrypoint), never as a loose registerScreen from the REPL. Register your screens in the component's awake/update and handle every id in its onCallback. (ui.click(callbackId, value?) fires one by hand — handy for testing without clicking.)

onClick works on ANY widget, not just button. Put onClick = "id" on an image (a clickable icon), a panel / vertical / horizontal (a whole clickable group — e.g. a desktop icon's image+label, a menu row), or a label (a menu item, a link). The engine senses the click over that widget's box — you do NOT need to overlay a transparent button. A real button nested inside still gets its own clicks (it's drawn on top).

Building a full-screen interactive app

A desktop, a HUD, a tool — anything that owns the whole viewport and reacts to input — is a component. Its lifecycle functions are top-level in the module (no self); module-level locals hold its state:

local function buildTree() ... end                  -- returns the widget tree

function awake()        ui.registerScreen("myapp", buildTree()) end
function update(dt)     ui.updateScreen("myapp", buildTree()) end   -- on state change
function editorUpdate(dt) update(dt) end            -- also tick in edit mode (play is paused there)
function onCallback(callbackId, data) ... end       -- handle every onClick id here

Presentation specifics, all discoverable but not obvious:

  • An opaque root. Containers are transparent by default (like a CSS element with no background): with nothing painted, the 3D scene shows through. Give your root an opaque background, or use a centralPanel root (it fills the viewport opaquely). A child container only needs a background when you want a fill; otherwise it shows its parent.
  • Stacking / z-order. ui.registerScreen(name, tree, layer) — a higher layer draws on top. Manage window focus by bumping a window's layer when it's clicked.
  • Draggable windows. Make each window its own screen whose ROOT is an area with movable = true (an area must be a screen root — it cannot nest inside another tree). The area keys — id, pos, movable, resizable, pivot, interactable — sit at the top level of the node, not inside props (a props placement still works but logs a prop-shape-misplaced warning). Give the area an explicit idui.getAreaPos(id) (→ a KEYED { x = , y = }, not {[1],[2]}) / ui.setAreaPos(id, x, y) key off it, and without one the dragged position is never recorded (they return nil). (getAreaPos/getAreaSize read the LAST PUBLISHED frame, so a read in the same execute that just registered the area is stale — query in a later step.) The declared pos is the window's position: changing the pos value re-seats the window there, while re-registering with the same pos leaves a user-dragged window exactly where the user dragged it (so a normal per-frame re-render never yanks the window around). To snap a dragged window back to an unchanged pos, call ui.setAreaPos(id, x, y). position:absolute, flex, and justify inside a window resolve against the window box when the area has an explicit width/height.
  • Resizable windows. Add resizable = true (a top-level area key, like movable) for a bottom-right drag grip the user can pull to resize the window. The dragged size is remembered per id and overrides the declared width/height from then on (a per-frame re-render keeps the user's size). Read the current measured size with ui.getAreaSize(id){ w = , h = } (a KEYED table, not {[1],[2]}; nil if the area didn't render this frame) to persist or react to it. The grip needs no explicit size to start from — a content-sized window becomes resizable too. Maximize / restore / tile programmatically with ui.setAreaSize(id, w, h) (it sets the same remembered size the grip drags), and ui.resetAreaSize(id) to drop the override back to the declared/content size. Changing the declared width/height also re-seats the size (the size analogue of the declared-pos re-seat) — so re-registering a maximized window with its smaller original size shrinks it back; an unchanged declared size leaves a user resize alone. (You no longer need to mint a fresh area id per geometry change.)
  • Docked panels sit below floating windows — maximize to the work area. A bottomPanel/topPanel taskbar is docked chrome and a floating area (a window) always draws above it, whatever the screen layer. That's correct for a real desktop: a window never goes under its own taskbar — instead a maximized window fills the work area (the screen minus the taskbar), so it ends flush at the taskbar's top edge. Size/position a maximized window to ui.screenSize().height − taskbarHeight (and ui.setAreaSize/ui.setAreaPos), not the full screen, and the taskbar stays visible without any z-order fight.
  • One coordinate space: ui.screenSize(). NEVER measure off the screenshot. Everything geometric — an area's pos, every widget width/height, ui.getLayoutInfo rects, ui.getAreaPos/getAreaSize, anchors — lives in ONE logical space whose size is ui.screenSize() (e.g. { width = 5120, height = 2880 }). They are all consistent with each other. A capture PNG is a downscaled copy of that canvas (e.g. a 1024-px-wide image of a 5120-wide canvas — a 5× shrink), so a position or size read off the capture image's pixels is wrong by the downscale factor — the classic trap is setting pos = {100, 50} because that "looks right" on the screenshot, which in a 5120-wide canvas lands the window in the top-left corner. Rules that always hold: (1) size and place using ui.screenSize() — center a 1200-wide window with x = ui.screenSize().width/2 - 600; pin a Start menu above a 96-tall taskbar at y = ui.screenSize().height - taskbarH - menuH. (2) To check where something actually landed, read ui.getLayoutInfo(id) (canvas space) — do NOT eyeball the capture. (3) A window sized a few hundred units is tiny on a multi-thousand-unit canvas; scale your numbers to ui.screenSize(), not to the screenshot.
  • Fill the remaining window height with an explicit height, not flexGrow. A floating area is content-sized — it has no bounded main axis — so a flexGrow child inside it hugs its content (it does NOT fill, because there's nothing to grow into; CSS treats flex-grow against an auto-height container as no growth). To make a window body fill the space under a title bar, give the window area (or a panel inside it) an explicit height; flexGrow then distributes the bounded space as expected. flexGrow is for bounded containers (a sized window, a docked panel) — not the free area itself.
  • A live clock / animation. Drive it from update/editorUpdate (edit mode is paused, so use editorUpdate there) — recompute the label and updateScreen. Put per-frame content in its OWN screen (a tiny taskbar/clock screen), and do NOT re-register a movable/resizable window's screen every frame just to tick a clock elsewhere — re-register a window only when ITS state changes. A window keeps its dragged position across re-registers with an unchanged declared pos (and size), but rebuilding every window every frame is wasted work and easy to get subtly wrong; scope each updateScreen to the screen that actually changed.
  • A registered screen is observable next frame, not the same call. registerScreen/updateScreen queue the change; it's applied at the frame boundary, and a screen's layout only exists after a render pass. So ui.listScreens() / ui.getLayoutInfo(id) called in the SAME execute that registered the screen do NOT see it yet — they read the last published frame. Register in one step and query in a later one (a capture or a follow-up execute is already frames later). This is the immediate-mode model, not a bug: you declare the tree, the engine renders it, and only then is its geometry measurable.

Authentic chrome, fonts, and images

  • 3D-beveled chrome, gradients, 9-slice, crisp pixels are all ZuiStyle keys, not hand-drawn — borderStyle = "outset"|"inset" + borderLight/borderDark (the raised/sunken Win95 edge), gradient = "linear-gradient(...)", backgroundFit = "9slice" + backgroundSlice. See the styling guide. A texture asset's own settings.filter = "nearest" keeps pixel art crisp wherever it's used.
  • Square corners. The active theme applies a default corner radius to panels/cards. A borderStyle bevel already draws square corners, but a plain background panel inherits the theme radius. For square, retro (Win95/DOS) chrome, set borderRadius = 0 on the widget, or zero the theme's radius-* tokens once with ui.registerTheme + ui.setTheme to make the whole UI square.
  • Vertical banners (rotated text). A label with props = { rotate = -90 } reports its rotated footprint to layout (a tall, narrow box), so a vertical side-banner is a normal flex child: put the rotated label in a banner column (vertical) with a background/gradient and a height — content-sized or explicit width both work — and it lays out beside the rest of the menu. Only if a sibling squeezes the banner narrower than the rotated text does the glyph run fall back horizontal; give the banner enough width, or pin the label with position = "absolute" + an edge inset (left/bottom), to keep it vertical.
  • Fonts. Drop a .ttf/.otf into the project and the importer bakes it into a usable .font asset automatically (no manual calls); select it with style = { fontFamily = "<name>" }.
  • Images. An image widget's src (and any backgroundImage style) names a texture asset — by name, path, or guid (no @ needed). The engine resolves and materialises it for you. It is NOT a raw file path or an inline data: URI; import the image to a .texture first (dropping a .svg/.png in does this).
  • Wrapping text. A label lays out on one line by default (so a label in a row sizes that row to its text). For paragraph text that must wrap inside a fixed box — a window body, a dialog, a tooltip — set props = { wrap = true }; it then wraps to the width of the container it sits in. Put the width on the container, never on the label: the label must have NO width of its own (a label with both wrap and a width collapses to one character per line), and its parent — a panel (or area/centralPanel/column) — carries the explicit width to wrap against. Without a width somewhere the line extends and stretches the box. Absolute/fixed sizing, position:absolute + left/top, and explicit width/height all resolve the same way in every container (panel, area, centralPanel) — a child stays the size you give it instead of inflating to fill.

Styling and themes

ui.defineStyle("title", { fontSize = 24, fontWeight = "bold" })
local node = { type = "label", props = { text = "Hi" }, classes = { "title" } }
ui.setTheme("dark")             -- switch the active theme
ui.getToken("color.primary")    -- read a design token

UI in the 3D world

For world-space UI — nameplates, floating health bars — add a UiPanel component to an entity and give it a tree:

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

Two ways to write the tree — and which to use

This guide uses raw widget tables ({ type = "...", props = {...}, children = {...} }) passed to ui.registerScreen. That is the canonical, fully-documented surface — reach for it. There is also an optional ergonomic builder, local Z = require("@builtin::modules.zui"), whose Z.panel{...} / Z.label"..." helpers produce the same trees with less punctuation. Use whichever you prefer; they interop freely (a Z.* node is just a table you can nest in a raw tree and vice-versa). When in doubt, write raw tables — everything here works with them.

The builders take positional arguments, and a few orderings are easy to get wrong on the first call. Each builder's module documents its exact signature in an --!arg block (read it, or write the raw { type = ..., props = ... } table) — the orderings that bite most often:

  • Z.slider(id, value, lo, hi, opts) — the range is positional (lo, hi); min/max are not opts keys, so passing { min = .., max = .. } leaves the range at the default 0..1. Z.dragValue, Z.fader, and Z.knob take the same (id, value, lo, hi, opts) shape.
  • Z.checkbox(label, id, checked, opts) and Z.toggle(label, id, checked, opts) — the label is first, the id second.
  • Z.spacer(pixels) — a single number, not { height = ... }.
  • Z.anchor(anchorPos, margin, children, opts) — the 2nd argument is a margin table { top, right, bottom, left }; a { style = ... } table in that slot is read as a margin and mis-places the anchor.

When a script errors

An uncaught error in a task/coroutine surfaces as a top-centre __task_error_overlay screen (so failures aren't a silent freeze) and is also logged — run logs.errors() for the full stack. The overlay auto-clears a few seconds after the last error while the world is playing; in edit mode the timer is paused, so dismiss it on demand with ui.unregisterScreen("__task_error_overlay") (it's an ordinary screen).

Finding the rest

lsp.methods("ui") lists the full surface (areas, panels, scroll, background shaders, validation). The model to carry: UI is a declared tree you register and refresh, and ui.getWidgetTypes/getWidgetProps tell you what's available without guessing.

  • documentation
  • guide