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 _G — type(_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 (alongsideawake/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 opaquebackground, or use acentralPanelroot (it fills the viewport opaquely). A child container only needs abackgroundwhen you want a fill; otherwise it shows its parent. - Stacking / z-order.
ui.registerScreen(name, tree, layer)— a higherlayerdraws 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
areawithmovable = true(anareamust be a screen root — it cannot nest inside another tree). Theareakeys —id,pos,movable,resizable,pivot,interactable— sit at the top level of the node, not insideprops(apropsplacement still works but logs aprop-shape-misplacedwarning). Give theareaan explicitid—ui.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 returnnil). (getAreaPos/getAreaSizeread the LAST PUBLISHED frame, so a read in the sameexecutethat just registered the area is stale — query in a later step.) The declaredposis the window's position: changing theposvalue re-seats the window there, while re-registering with the sameposleaves 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 unchangedpos, callui.setAreaPos(id, x, y).position:absolute, flex, andjustifyinside a window resolve against the window box when theareahas an explicitwidth/height. - Resizable windows. Add
resizable = true(a top-levelareakey, likemovable) for a bottom-right drag grip the user can pull to resize the window. The dragged size is remembered peridand overrides the declaredwidth/heightfrom then on (a per-frame re-render keeps the user's size). Read the current measured size withui.getAreaSize(id)→{ w = , h = }(a KEYED table, not{[1],[2]};nilif 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 withui.setAreaSize(id, w, h)(it sets the same remembered size the grip drags), andui.resetAreaSize(id)to drop the override back to the declared/content size. Changing the declaredwidth/heightalso re-seats the size (the size analogue of the declared-posre-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 areaidper geometry change.) - Docked panels sit below floating windows — maximize to the work area. A
bottomPanel/topPaneltaskbar is docked chrome and a floatingarea(a window) always draws above it, whatever the screenlayer. 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 toui.screenSize().height − taskbarHeight(andui.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 — anarea'spos, every widgetwidth/height,ui.getLayoutInforects,ui.getAreaPos/getAreaSize, anchors — lives in ONE logical space whose size isui.screenSize()(e.g.{ width = 5120, height = 2880 }). They are all consistent with each other. AcapturePNG 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 settingpos = {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 usingui.screenSize()— center a 1200-wide window withx = ui.screenSize().width/2 - 600; pin a Start menu above a 96-tall taskbar aty = ui.screenSize().height - taskbarH - menuH. (2) To check where something actually landed, readui.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 toui.screenSize(), not to the screenshot. - Fill the remaining window height with an explicit height, not
flexGrow. A floatingareais content-sized — it has no bounded main axis — so aflexGrowchild inside it hugs its content (it does NOT fill, because there's nothing to grow into; CSS treatsflex-growagainst an auto-height container as no growth). To make a window body fill the space under a title bar, give the windowarea(or a panel inside it) an explicitheight;flexGrowthen distributes the bounded space as expected.flexGrowis 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 useeditorUpdatethere) — recompute the label andupdateScreen. 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 declaredpos(and size), but rebuilding every window every frame is wasted work and easy to get subtly wrong; scope eachupdateScreento the screen that actually changed. - A registered screen is observable next frame, not the same call.
registerScreen/updateScreenqueue the change; it's applied at the frame boundary, and a screen's layout only exists after a render pass. Soui.listScreens()/ui.getLayoutInfo(id)called in the SAMEexecutethat registered the screen do NOT see it yet — they read the last published frame. Register in one step and query in a later one (acaptureor a follow-upexecuteis 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
ZuiStylekeys, 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 ownsettings.filter = "nearest"keeps pixel art crisp wherever it's used. - Square corners. The active theme applies a default corner radius to panels/cards. A
borderStylebevel already draws square corners, but a plainbackgroundpanel inherits the theme radius. For square, retro (Win95/DOS) chrome, setborderRadius = 0on the widget, or zero the theme'sradius-*tokens once withui.registerTheme+ui.setThemeto make the whole UI square. - Vertical banners (rotated text). A
labelwithprops = { 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 abackground/gradientand aheight— 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 withposition = "absolute"+ an edge inset (left/bottom), to keep it vertical. - Fonts. Drop a
.ttf/.otfinto the project and the importer bakes it into a usable.fontasset automatically (no manual calls); select it withstyle = { fontFamily = "<name>" }. - Images. An
imagewidget'ssrc(and anybackgroundImagestyle) 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 inlinedata:URI; import the image to a.texturefirst (dropping a.svg/.pngin does this). - Wrapping text. A
labellays 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 — setprops = { 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 NOwidthof its own (a label with bothwrapand awidthcollapses to one character per line), and its parent — apanel(orarea/centralPanel/column) — carries the explicitwidthto wrap against. Without a width somewhere the line extends and stretches the box. Absolute/fixed sizing,position:absolute+left/top, and explicitwidth/heightall 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/maxare notoptskeys, so passing{ min = .., max = .. }leaves the range at the default0..1.Z.dragValue,Z.fader, andZ.knobtake the same(id, value, lo, hi, opts)shape.Z.checkbox(label, id, checked, opts)andZ.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.