Log inGet started

Styling

Updated 6 September 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 canonical, type-checked subset is the ZuiStyle schema in @builtin::modules.zui.style — the LSP autocompletes it and flags a misspelled key at the call site. This guide covers ZuiStyle and the CSS-parity keys the renderer consumes beyond it (animation, transition, opacity, backdropFilter, filter, outline, per-state variants). When you are unsure a key took, gui validate 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.

  • Sizewidth, height, and the minWidth / maxWidth / minHeight / maxHeight constraints. Omit a size and the box hugs its content.
  • Box modelpadding 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.
  • DistributionjustifyContent (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.
  • Growthflex = 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 flowposition = "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.
  • Clippingoverflow = "hidden" (or "clip") crops children to the box, following its borderRadius; overflow = "scroll" scrolls them.

There is no grid primitive and no table: a grid is a vertical of horizontal rows, and each cell is a box. This keeps one layout model.

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:

  • 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.
  • 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.
  • 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 gui fonts 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. A label and a button select their face from it; the other text-bearing widgets draw in the theme's face at whatever fontSize the style asks for. 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.

gui fonts is the same list from the tool surface — bundled families only, gui fonts true for the host-OS ones as well.

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: gui validate <screen> 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.

font.reconcile() and font.list() answer a different question: they walk the shaper's own family table, the one text.* 2D and 3D text is laid out from, which is a separate registry with its own spellings. A family they report as not selectable can still be a registered UI family, and the reverse — so neither verifies a fontFamily. ui.listFonts() is the list a fontFamily is checked against, and gui validate / ui.lastValidation is the verdict on one that did not take; topics/text covers the shaping side.

A label lays out on one line by default. For paragraph text that wraps, set props = { wrap = true } and put the width on the container, never on the label — a label with both wrap and a width collapses to one glyph per line.

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:

  • Entranceiterations = 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.
  • Idleiterations = "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). gui setTheme switches the active theme; gui themes lists them.

Where to look

  • ZuiStyle (@builtin::modules.zui.style) — the type-checked inline keys.
  • ZuiThemeTokens — the global theme tokens.
  • gui widgetProps <type> — a widget's props (its data + callback ids), distinct from its style.
  • gui validate — what the renderer rejected on the last render, including a fontFamily that named no registered family.
  • gui 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.