---
title: "Styling"
description: "Every visual property of a widget lives in one table — the style table on the widget node — and the vocabulary is CSS. If you know how a property behaves in a browser, it behaves the same way here:…"
section: "Topics"
slug: "topics-ui-styling"
canonical: "https://origozero.ai/docs/topics-ui-styling"
updated: "2026-09-06T05:08:08.325484361+00:00"
---

# Styling

```lua
{ 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.

- **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.

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.

```lua
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`:

```lua
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). `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.
