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 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<= 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.
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(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.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.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 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 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.
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:
- 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). 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'sprops(its data + callback ids), distinct from itsstyle.gui validate— what the renderer rejected on the last render, including afontFamilythat named no registered family.gui 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.