Log inGet started

Styling

Updated October 15, 2026
{ type = "vertical", style = {
    width = 320, padding = 16, gap = 12,
    background = "#16181d",
    borderRadius = 14,
    boxShadow = "0 10px 30px rgba(0,0,0,0.35)",
  }, children = { ... } }

The LSP checks a style table at the call site and flags a misspelled key against what the renderer accepts, so a wrong name is a diagnostic rather than a key that silently does nothing. This guide covers those keys and the CSS-parity ones the renderer consumes beyond the common set (animation, transition, opacity, backdropFilter, filter, outline, per-state variants). When you are unsure a key took, the gui toolbox's validate tool names anything the renderer rejected, and the templates in the world's source/ui/ show every key in real use.

How a value is written

  • Colors — a CSS string ("#rrggbb", "#rrggbbaa", "rgb(r,g,b)", "rgba(r,g,b,a)", a named colour, "transparent") or a numeric array ({r,g,b} / {r,g,b,a}; components <= 1 read as 0..1, otherwise 0..255).
  • Lengths — a number of logical pixels, or a "N%" string (a fraction of the parent's content box). For size keys, a plain number strictly between 0 and 1 also reads as a fraction, so width = 0.5 is half the parent and width = 320 is 320 px; use the "50%" string when you want no ambiguity.
  • Box-sides (padding, margin, backgroundSlice) — one number for all sides, {x, y} for a horizontal/vertical pair, or {top, right, bottom, left}. Per-side keys (marginTop, borderLeftWidth, …) override the shorthand.
  • Angles / durations / functions inside a CSS string follow CSS: linear-gradient(135deg, …), 0.4s, ease-out, blur(8px), translateY(16px) scale(0.96).

Layout — the box model and flexbox

Layout is flexbox. The two container types set the main axis: horizontal is a row, vertical is a column. Children flow along the main axis; the keys control how.

  • Size — width, height, and the minWidth / maxWidth / minHeight / maxHeight constraints. Omit a size and the box hugs its content.
  • Box model — padding insets the content from the border; margin insets the box from its siblings; gap is the spacing between children (default 8). Per-side paddingTop / marginLeft / … refine the shorthand. borderWidth sits between padding and margin, exactly as in CSS.
  • Distribution — justifyContent (alias justify) positions children along the main axis: start, center, end, space-between, space-around, space-evenly. alignItems (alias align) positions them across it: start, center, end, stretch.
  • Growth — flex = 1 makes a child take a share of the free main-axis space (shorthand for grow 1 / shrink 1 / basis 0); flexGrow, flexShrink, flexBasis are the longhands. Two siblings both at flex = 1 split the row evenly; flex = 2 versus flex = 1 splits it two-to-one.
  • Out of flow — position = "absolute" takes a child out of the flex flow and pins it with left / top / right / bottom, measured from the nearest positioned ancestor (a container with position set). This is how you place a badge on a card, an overlay over a panel, or compose freely instead of by flow. position = "fixed" pins to the viewport.
  • Clipping — overflow = "hidden" (or "clip") crops children to the box, following its borderRadius; overflow = "scroll" scrolls them.

A grid lays its children out in columns, row by row. gridTemplateColumns lists the column tracks: a length (120px or a bare number), a percentage of the grid (25%), a share of the free width (1fr), auto, or repeat(n, <track>). Lengths and percentages are what they say; auto tracks take their content's width; fr tracks share what is left, in proportion. When no fr track claims the free width, the auto tracks share it. Each cell is its track's width, and a % width inside a cell is a fraction of that track. A % size gives its box no width of its own for a track to fit, so a cell whose only content is a %-sized box, in an auto track beside an fr track, is 0 wide: give that box a length, or its track a length or an fr share. edui.table is a grid.

Fill — background, gradients, image, frost

A box's fill layers in a fixed order — solid colour, then gradient, then image — so you can stack them. They paint behind a widget's own box as well: a button, label, richText, canvas or image takes them exactly as a vertical does. A slider, checkbox and input, and the frame of a window, modal, popup, area or docked panel, take background as a solid colour on their own chrome; a gradient stated on one of those is reported by gui validate / ui.lastValidation as background-gradient-not-painted.

  • background (alias backgroundColor) — a solid colour. A container with no background is transparent, and the scene or parent shows through; give a root an opaque background so it reads as a surface. A gradient string in background paints as the gradient key does.
  • gradient — a CSS gradient string painted over the solid fill. All three CSS kinds are supported: linear-gradient(<angle>deg, <color> <pos>%, …) (angle by CSS convention — 0deg up, 90deg right, 180deg down), radial-gradient(<color> <pos>%, …) (a soft vignette or glow from the centre), and conic-gradient(from <angle>deg, <color> <deg>, …) (colour swept by angle — the dial, pie, and ring form). A single stop is a flat fill.
  • repeatingGradient — a repeating-linear-gradient(…) for diagonal stripe bands. dotPattern — a radial-gradient(…) tiled on the dotTile grid as a halftone dot screen. Both paint over the fill and clip to the box.
  • backgroundImage — a texture (by asset ref, name, or guid). backgroundFit maps it into the box: stretch (default), cover, contain, tile, or 9slice (corners fixed, edges/centre stretched — a resizable bordered bitmap; the corner insets are backgroundSlice). It fills the box's own outline, so a borderRadius or clipPath cuts it the way it cuts the fill. "none" is a box with no picture. While a reference is still being fetched the box paints a [loading <ref>] plate; a reference whose load failed paints a [failed <ref>] plate and names the reference once in the log. They are the same plates an image widget shows for the same reference, so a dead reference reads differently from a slow one and from a live one. imagePlaceholder = "none" on the box (or on an image widget) paints nothing in the picture's place instead, so the box's own fill shows until the texture arrives: the form a player-facing screen takes, where a plate naming a reference is no part of the design. A failed load is still named in the log.
  • backdropFilter = "blur(<n>px)" — frosts whatever is behind a translucent box, the CSS backdrop-filter: the frosted-glass look. Pair a translucent background (e.g. rgba(20,20,30,0.6)) with the blur.
  • backgroundShader — a render-target/shader pipeline drawn as a live, procedural fill.

Border and corners

borderColor + borderWidth stroke the box. Per-side borderTopWidth / borderRightWidth / borderBottomWidth / borderLeftWidth set edges independently — two lit edges make an L-shaped corner bracket, a common HUD motif. borderRadius rounds the corners: a number of pixels, or "50%" for a full circle/pill. Rounding clips the fill, the gradient, and (with overflow:hidden) the children.

outline is a stroke drawn outside the border box that consumes no layout space — the CSS outline, with outlineColor and outlineOffset pushing it out. Keyboard focus draws its own ring, styled by focusOutlineColor / focusOutlineWidth / focusOutlineCornerRadius.

Depth — shadow

boxShadow is the CSS box-shadow: one or more comma-separated layers, each [inset] <dx> <dy> <blur> <spread>? <color>.

  • An outer shadow (no inset) lifts the box off what is behind it — "0 10px 30px rgba(0,0,0,0.4)".
  • An inset shadow recesses or glazes the surface — "inset 0 2px 6px rgba(0,0,0,0.5)" for a pressed well, "inset 0 1px 0 rgba(255,255,255,0.4)" for a top sheen.
  • Stack layers with commas: a drop shadow, a coloured glow, and an inset highlight in one string.

Shadows follow the element's transform, so a rotated or scaled box keeps a correct halo. elevation = "sm" | "md" | "lg" | "xl" is a preset shadow when you do not want to spell one out.

Depth on a dark UI reads from contrast, not from a black shadow. A dark drop-shadow over a near-black background is invisible — the shadow colour and the background are the same, so a panel with only boxShadow still looks flat and pasted-on. On a dark theme, separate a panel from its background with a step in lightness and a lit top edge, and keep the drop-shadow as a soft supporting cue:

  • Lift the fill. The panel's background must be a visible step lighter than what sits behind it — a #0e1116 page under #1a2029 panels, not #0a0d12 under #12161d (a step so small the eye cannot see it). A subtle top-to-bottom linear-gradient on the panel (lighter at the top) mimics a light from above and reads as a raised surface.
  • Light the top edge. A one-pixel inset highlight — "inset 0 1px 0 rgba(255,255,255,0.06)" — catches the imagined light on the panel's upper rim; a hairline borderColor a shade lighter than the fill defines the whole edge. These do more for perceived elevation on dark UIs than any drop-shadow.
  • Glow the accents. Depth cues on dark themes are colour, not just black: a coloured outer shadow on a selected or active element ("0 0 16px rgba(60,231,255,0.25)") makes it float above its neighbours.
  • Then add the black drop-shadow ("0 12px 30px rgba(0,0,0,0.35)") as the final, softest layer — stacked after the highlight and glow — for the last bit of separation. It supports the lightness step; it cannot replace it.

Effects — opacity, transform, filter, clip-path

  • opacity — 0..1, fades the box and its whole subtree (CSS opacity).
  • transform — the CSS transform, space-separated about the box centre and cascaded to the subtree: translate() / translateX() / translateY(), scale() / scaleX() / scaleY(), rotate(), skewX() / skewY(). Purely visual — it does not disturb layout, which is what makes it the right thing to animate.
  • filter — the CSS filter on the box and its subtree: blur(<n>px), drop-shadow(…), brightness(), contrast(), grayscale(), saturate(), and the rest, space-separated.
  • clipPath = "polygon(x y, …)" — the CSS clip-path: each coordinate a % of the border box or a px length. Clips fill, gradient, border, and subtree to an arbitrary polygon (a slanted card, a chevron), and the transform maps the result.

Text

On a label (and any text-bearing widget): color, fontSize, fontFamily ("monospace", "proportional", or a registered font family — ui.listFonts() and the gui toolbox's fonts tool name every one of them), fontWeight (CSS 100..900 or "bold"; ≥700 synthesises a heavy face), fontStyle ("italic" / "oblique"), letterSpacing (px or a CSS "0.06em"), lineHeight (a unitless multiplier or a CSS "150%" / "24px"), textAlign (left / center / right), textTransform (uppercase / lowercase / capitalize), textDecoration (underline / line-through), textShadow (CSS "<dx> <dy> <blur>? <color>", comma-separated layers), and textStroke (CSS -webkit-text-stroke, "<width>px <color>") for an inked glyph outline.

Which font families fontFamily can select

fontFamily takes a CSS family list, and each name in it is matched against the families the UI text renderer has registered. Every widget that draws text selects its face from it — label, button, checkbox, input, richText and a collapsible's header — and so does a canvas text paint command, through its own font field. ui.listFonts() is that list — one row per family carrying:

  • family — the name to write in fontFamily.
  • aliases — the other names that select the same family: web-font names ("arial", "georgia", "times new roman"), CSS generic families ("serif", "monospace", "cursive", "fantasy"), and the face names registered under it. Matching is case-insensitive.
  • faces — the concrete face in each of the regular / bold / italic / boldItalic slots. fontWeight and fontStyle pick among these, so a family with a real bold face renders one, and a family with only regular gets a synthesised heavy.
  • system — true when the family came from the host OS rather than a bundled font asset. A host-OS family renders on the machine whose OS has it, and the web build reads no OS fonts at all, so a UI that must look the same everywhere picks a family whose system is false.

fonts is the same list from the tool surface — bundled families on its own, and the host-OS ones as well when asked for them.

for _, f in ui.listFonts() do
    print(f.family, table.concat(f.aliases, ", "))
end
-- tinos    liberation serif, serif, times, times new roman, ...
-- gelasio  georgia
-- cousine  courier, courier new, liberation mono, monospace, ...

A fontFamily naming a family nothing has registered paints in the default proportional face, and the screen reports it: validate and ui.lastValidation(screen) both carry an unknown-font-family warning once that screen has painted, naming the family you asked for and the families that are registered. It is one warning per family per screen, and the text keeps being drawn — a missing font leaves the words on screen in the fallback face.

One registration makes a face answer to every name it was registered under — the name its .font asset carries, the family group it stands in, the web-font and generic names its descriptor declares, and the family the font file itself declares — and each of those names selects it on every text surface, the UI widgets and text.* 2D and 3D alike. So font.list() and font.reconcile(), which read the same faces from the shaper's side, name faces a fontFamily reaches; font.reconcile()'s shapedWith is the family a name selects, spelled the way the font file spells it. ui.listFonts() remains the list the UI resolves against, and the one that also carries the host-OS families a text.* layout has of its own; validate / ui.lastValidation is the verdict on a family that took nothing, and topics/text covers the shaping side.

Text in any script: per-glyph fallback and on-demand faces

Every family falls back glyph by glyph to every other face the UI holds, so a character the selected family lacks is drawn from a face that has it, whatever fontFamily names. Japanese, Korean and Chinese (simplified and traditional) need no setup: the built-in library declares a CJK face (Noto Sans CJK SC, in chunks by script and by how common a character is) whose files are read only when a label first shows a character one of them covers. The first frame draws that character as a box; the face is read and installed within a few frames, and from then on every label draws it. A session that shows no CJK text never reads those files.

  • ui.missingGlyphs(): the characters the UI is showing without a glyph (as the box), across every surface it paints. Empty is the answer you want; a character that stays in it is one no face the UI holds, or can load, covers.
  • font.onDemand(): every declared on-demand face and where it stands: declared, requested (with requestedBy, the character that asked), installed (with the bytes it loaded) or failed (with error).
  • font.prefetch(text): start reading the faces text needs before the screen showing it paints.
  • font.ready(text): true once every on-demand face text needs is installed, so a screen opened then draws every glyph on its first frame.

A font of your own joins the same fallback: register it as a .font asset (asset.create("font", name, { bytes = ... })) and every label draws the characters it covers that the selected family lacks. A large face can be declared on demand instead of read at boot; types/font shows the layout.

local text = "日本語のメニュー"
font.prefetch(text)                    -- read the faces before the screen opens
task.spawn(function()
    while not font.ready(text) do task.wait() end
    ui.showScreen("menu")              -- its first frame draws every glyph
end)
for _, f in ipairs(font.onDemand()) do print(f.name, f.state, f.requestedBy) end
print(#ui.missingGlyphs())             -- 0 once every character has a glyph

A session starts with every on-demand face unread, and a face stays installed for the rest of the session, so a screen's first open in a fresh session is the one a player sees. Within one session, text whose faces another screen (or the agent terminal) already drew is ready at once: font.ready and font.onDemand() say which case a check is in.

A label lays out on one line by default. For paragraph text that wraps, set props = { wrap = true }. The lines are laid out against the width the label's box resolves to — its own width when it has one, otherwise the width its container gives it, whatever that container's own alignItems. textAlign places each of those lines inside the box, exactly as it places the single line of an unwrapped label. A wrapping label whose box comes out narrower than its own longest word — the width below which the wrap has nothing to break on but the glyphs — is reported as a wrap-label-no-width warning through validate and ui.lastValidation(screen), naming both widths.

Interaction states

A widget can restyle itself when the pointer is over it or pressing it. The quick keys cover the common case: backgroundHover / colorHover (fill and text while hovered) and backgroundActive (fill while pressed). For a full alternate look per state, give the style a states map keyed by the CSS pseudo-class (":hover", ":active", ":focus") whose value is a style table merged in when that state is on.

transition smooths a state change instead of snapping it — the CSS transition, e.g. "0.18s ease" or a per-property form, so a hover fill fades in over its duration rather than switching instantly. Transitions animate the response to input; animation (below) animates on its own timeline.

Motion — animation

Any node animates through an animation table on its style, modelled on CSS @keyframes:

animation = {
  keyframes = {
    ["0%"]   = { opacity = 0, transform = "translateY(16px)" },
    ["100%"] = { opacity = 1, transform = "translateY(0px)" },
  },
  duration = 0.55,        -- seconds
  delay = 0.1,            -- seconds before it starts
  easing = "ease-out",    -- linear | ease | ease-in | ease-out | ease-in-out
  fill = "both",          -- hold the first frame before the delay, the last after
  iterations = 1,         -- a number, or "infinite"
}

Each keyframe is a percentage key ("0%", "50%", "100%") mapping to a style fragment; the renderer interpolates the animatable properties between them every frame. Animate opacity and transform for entrances and idle motion — they move without touching layout. boxShadow and colours also tween, which is how a breathing glow or a pulsing accent is done.

Two shapes earn their keep:

  • Entrance — iterations = 1 with fill = "both". Because fill holds the first keyframe until delay elapses, staggering the delay across sibling panels makes them cascade in one after another instead of all at once.
  • Idle — iterations = "infinite" for a slow bob, a breathing glow, a pulsing live-dot. Keep the amplitude small.

Animations advance in play, not edit. Edit mode pauses gameplay, and time along with it, so a still captured in edit shows a frozen frame — flip to play (wld play) to see motion run, and capture a time-sampled collage to confirm an entrance cascades and settles or an idle loop keeps moving.

Themes and tokens

Inline style always wins, but a shared look belongs in a theme, which reaches further than per-widget style:

  • A theme's styles maps a widget class to a style table, applied to every node carrying that class — the same ZuiStyle vocabulary, with $token indirection.
  • A theme's tokens is the global layer: a fixed set of named tokens the engine applies to the chrome every box, window, and widget gets by default — the outline on every box (border + border-width), the surface fills (card, popover, surface), the roundings (radius-widget, radius-lg), the focus ring (ring), the window shadow. The full set is the ZuiThemeTokens type. Zeroing border / border-width flattens the whole UI's default outline in one move.

So the cascade is, outermost to innermost: theme tokens (global chrome) → theme styles (per class) → inline style (per node). The gui toolbox's setTheme tool switches the active theme; themes lists them.

Where to look

The gui toolbox answers for what the renderer did with a style:

  • validate / ui.lastValidation — the verdict on a style a screen registered: a key the renderer does not take, and a fontFamily that selected no face, each named against the widget that carried it. It also reports what the renderer rejected on the last render.
  • ui.getTheme / themes — the active theme and what is installed.
  • widgetProps — a widget's props (its data + callback ids), distinct from its style.
  • fonts / ui.listFonts() — the font families fontFamily can select, with the aliases and weight/style faces of each.
  • source/ui/ — every key above, in complete working screens.