Styling
{ 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<= 1read 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, sowidth = 0.5is half the parent andwidth = 320is 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 theminWidth/maxWidth/minHeight/maxHeightconstraints. Omit a size and the box hugs its content. - Box model —
paddinginsets the content from the border;margininsets the box from its siblings;gapis the spacing between children (default 8). Per-sidepaddingTop/marginLeft/ … refine the shorthand.borderWidthsits between padding and margin, exactly as in CSS. - Distribution —
justifyContent(aliasjustify) positions children along the main axis:start,center,end,space-between,space-around,space-evenly.alignItems(aliasalign) positions them across it:start,center,end,stretch. - Growth —
flex = 1makes a child take a share of the free main-axis space (shorthand for grow 1 / shrink 1 / basis 0);flexGrow,flexShrink,flexBasisare the longhands. Two siblings both atflex = 1split the row evenly;flex = 2versusflex = 1splits it two-to-one. - Out of flow —
position = "absolute"takes a child out of the flex flow and pins it withleft/top/right/bottom, measured from the nearest positioned ancestor (a container withpositionset). 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 itsborderRadius;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(aliasbackgroundColor) — a solid colour. A container with nobackgroundis transparent, and the scene or parent shows through; give a root an opaquebackgroundso it reads as a surface. A gradient string inbackgroundpaints as thegradientkey 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 —0degup,90degright,180degdown),radial-gradient(<color> <pos>%, …)(a soft vignette or glow from the centre), andconic-gradient(from <angle>deg, <color> <deg>, …)(colour swept by angle — the dial, pie, and ring form). A single stop is a flat fill.repeatingGradient— arepeating-linear-gradient(…)for diagonal stripe bands.dotPattern— aradial-gradient(…)tiled on thedotTilegrid as a halftone dot screen. Both paint over the fill and clip to the box.backgroundImage— a texture (by asset ref, name, or guid).backgroundFitmaps it into the box:stretch(default),cover,contain,tile, or9slice(corners fixed, edges/centre stretched — a resizable bordered bitmap; the corner insets arebackgroundSlice). It fills the box's own outline, so aborderRadiusorclipPathcuts 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 animagewidget 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 animagewidget) 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 translucentbackground(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
insetshadow 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
backgroundmust be a visible step lighter than what sits behind it — a#0e1116page under#1a2029panels, not#0a0d12under#12161d(a step so small the eye cannot see it). A subtle top-to-bottomlinear-gradienton 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 hairlineborderColora 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 CSStransform, 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 CSSfilteron the box and its subtree:blur(<n>px),drop-shadow(…),brightness(),contrast(),grayscale(),saturate(), and the rest, space-separated.clipPath = "polygon(x y, …)"— the CSSclip-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 infontFamily.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 theregular/bold/italic/boldItalicslots.fontWeightandfontStylepick among these, so a family with a realboldface renders one, and a family with onlyregulargets 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 whosesystemis 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(withrequestedBy, the character that asked),installed(with thebytesit loaded) orfailed(witherror).font.prefetch(text): start reading the facestextneeds before the screen showing it paints.font.ready(text): true once every on-demand facetextneeds 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 = 1withfill = "both". Becausefillholds the first keyframe untildelayelapses, staggering thedelayacross 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
stylesmaps a widget class to a style table, applied to every node carrying that class — the sameZuiStylevocabulary, with$tokenindirection. - A theme's
tokensis 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 theZuiThemeTokenstype. Zeroingborder/border-widthflattens 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 afontFamilythat 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 itsstyle. - fonts /
ui.listFonts()— the font familiesfontFamilycan select, with the aliases and weight/style faces of each. source/ui/— every key above, in complete working screens.