Log inGet started

Styling zui widgets

Every visual knob a widget exposes lives in its style block — a plain table under the style key of the widget node — and the complete vocabulary of that block is one typed schema: ZuiStyle, defined…

local Z = require("@builtin::modules.zui")

Z.panel({
    style = {
        background = "#16181d",
        borderRadius = 2,
        padding = { 8, 12 },        -- {vertical, horizontal}
        shadow = { offset = { 0, 4 }, blur = 12, color = { 0, 0, 0, 120 } },
    },
}, children)

Because every builder's style opt is typed as ZuiStyle?, the LSP autocompletes the keys and flags typos at the call site. A misspelled key is a type error, not a silent no-op.

How values are written

  • Colors are strings: "#rrggbb", "#rrggbbaa", a named color, or "transparent".
  • Sizes (width, height, min*/max*, fontSize, insets) are numbers in pixels or "N%" strings. For the box-size keys, a plain number strictly between 0 and 1 reads as a fraction of the parent's content box (the screen for a top-level widget) — width = 0.5 is half the parent, width = 320 is 320 px. The bounds are exclusive: width = 1 is 1 px (not full width) and width = 0 is 0 px — use the "100%" / "50%" string when you mean a percentage and want no ambiguity.
  • padding / margin take a number (all sides), {x, y} (horizontal/vertical pair), or {top, right, bottom, left}. Per-side marginTop/Right/Bottom/Left override the shorthand.
  • shadow is a keyed table — { offset = {x, y}, blur, spread, color = {r, g, b, a} } with 0–255 color channels; alpha 0 turns the shadow off. elevation = "sm" | "md" | "lg" | "xl" is the preset alternative; an explicit shadow wins.
  • Corner rounding is borderRadius — the one and only spelling.

One schema, many widgets — what applies where

ZuiStyle is a shared vocabulary, not a promise that every widget consumes every key. A spacer has no text to color; a slider has no padding box. The broad strokes:

  • Containers (Z.panel, Z.vbox, Z.hbox, the screen-edge panels, Z.area, Z.window, Z.modal, Z.popup) honor the fill/border/box-model group: background, borderColor/borderWidth/borderRadius, padding, margin, shadow/elevation, the size constraints, and the layout keys (gap, align, justify, the flex* family, position + insets). Panels additionally take backgroundImage (+ backgroundFit/backgroundSlice), gradient, and backgroundShader — see "Backgrounds" below.
  • Text-bearing widgets (Z.lbl, Z.btn, Z.checkbox, Z.input, Z.collapsible, Z.richText) honor the text group: color, fontSize, fontFamily, fontWeight = "bold", textDecoration = "underline", textAlign — plus their own fills and borders. Hover/press feedback comes from backgroundHover, backgroundActive, colorHover.
  • Value widgets: Z.slider reads background (track) and color (fill/grab); Z.checkbox reads color (check mark), background (box) and borderColor. Z.btn alone adds iconColor, suffixColor, suffixSize for its icon/suffix decorations.
  • Media leaves (Z.image, Z.viewport) honor width / height — a leaf is not exempt from sizing. Give either a pixel size or a "N%" / fractional size and it takes it; the %/fraction resolves against the parent's content box (like a container — not the whole screen). With no size, Z.image falls back to a 64 px icon and Z.viewport fills its slot, so an unsized image stays small rather than ballooning to the texture's native pixels. Z.image additionally maps the texture into that box via backgroundFit (stretch/cover/contain/tile/9slice) + backgroundSlice.
  • Keyboard focus on any focusable widget draws a focus ring styled by focusOutlineColor, focusOutlineWidth, focusOutlineCornerRadius.
  • Composite builders (Z.meter, Z.fader, Z.knob, …) are Luau compositions over Z.canvas; where they need extra knobs (fillColor, trackColor, arcWidth, …) their module exports an extended type — MeterStyle = ZuiStyle & { … } — so the extras are just as visible and checked as the core schema.

Two caveats the type cannot express, so learn them here:

  • Z.window and Z.modal only apply their frame styling once background is set. Without it they use the default chrome and ignore borderRadius/borderColor/borderWidth.
  • A handful of widgets have no styleable surface at all: Z.datePicker, Z.colorPicker, Z.table, and Z.scene render entirely from the global theme, so their style block is accepted but unused today. Z.spacer is invisible too, but it is a layout strut: it consumes width / height (or the space prop) to size the gap it reserves — it just carries no surface to color or border.

Backgrounds: images, 9-slice, and gradients

A container's fill is more than a flat background colour. Three keys paint richer fills, and they layer in a fixed order: the solid background first, then gradient over it, then backgroundImage on top. Each is independent — use one or stack them.

backgroundImage names a texture (an asset ref or guid). How it maps onto the box is backgroundFit:

  • stretch (default) — the texture is scaled to the box, ignoring aspect.
  • cover — scaled to fill the box, cropping the overflow (aspect preserved).
  • contain — scaled to fit inside the box, letterboxed (aspect preserved).
  • tile — drawn at native size, repeated to fill.
  • 9slice — the texture is cut into a 3×3 grid: the four corners stay fixed, the four edges stretch along one axis, and the centre stretches both ways. This is how a single small bitmap becomes a resizable bordered panel — a window frame, a button bevel, a beveled group box — without the corners distorting. The cut is set by backgroundSlice: the inset from each edge in source pixels, written [top, right, bottom, left] or a single number for all four. So a 24×24 frame texture with a 8px border is { backgroundImage = "@me::frame", backgroundFit = "9slice", backgroundSlice = 8 }.
-- A resizable window-chrome panel from one 9-slice bitmap.
Z.panel({ style = {
  backgroundImage = "@me::ui.window_frame",
  backgroundFit   = "9slice",
  backgroundSlice = { 6, 6, 6, 6 },   -- 6px corners, edges/centre stretch
  padding = 10,
}}, children)

gradient is a CSS-ish linear-gradient(...) string, painted as a smooth multi-stop ramp behind the content (and over any solid background):

Z.panel({ style = {
  gradient = "linear-gradient(180deg, #2b5cff 0%, #0a1e6e 100%)",
}}, children)

The angle follows the CSS convention: 0deg = to top, 90deg = to right, 180deg = to bottom (the default if you omit the angle), 270deg = to left. Stops without an explicit % are spread evenly; a single stop becomes a flat fill. Any colour spelling the rest of the schema accepts works in a stop.

backgroundShader names a render-target/background-shader pipeline drawn as the fill — a live procedural or animated background. (See the shaders guide for authoring screen-domain shaders.)

Borders: solid and 3D bevels

borderColor + borderWidth paint a uniform stroke. borderStyle changes how that border is drawn — it's the standard CSS border-style vocabulary, so the same key serves a flat modern outline and a chunky retro 3D edge with no special-casing:

  • solid (default) — the uniform stroke.
  • outset — a raised bevel: lit top-left, shadowed bottom-right (a button that pops out).
  • inset — a sunk bevel: the reverse (a pressed button / text field).
  • ridge — a raised line; groove — a carved line (double bevels).

A bevel paints four tones derived from borderColor (its face colour), so it works for any palette without hand-tuning:

-- A raised "button" face, classic 3D chrome:
Z.panel({ Z.lbl("OK") }, { style = {
  background  = "#c0c0c0",
  borderStyle = "outset",
  borderWidth = 3,
} })

borderLight / borderDark override the highlight / shadow tones when you want exact colours. Bevels draw square corners (the authentic look) and ignore borderRadius. A modern UI simply leaves borderStyle unset and uses borderRadius + shadow instead — the kit spans both ends with the same keys.

Theming vs. inline style — and the global chrome

Inline style blocks win, but shared looks belong in themes, and themes reach further than per-widget styling: a theme has two halves.

  • styles maps widget classes to style tables (["button.primary"] = { background = "$accent" }) with $token indirection — the cascade applies them to every widget carrying that class. The vocabulary is the same ZuiStyle schema.

  • tokens is the global layer — and this is the one most people miss. The engine reads a fixed set of named tokens and applies them to the global chrome that egui paints on every container, window, and widget: the 1px outline on every box, the window/panel fills, scrollbars, shadows. That set is typed as ZuiThemeTokens (@builtin::modules.zui.style).

    The single most useful fact: border + border-width are the outline you see on every box. A theme with border = "transparent" (or border-width = "0") flattens the whole UI in one move — that outline is not a hardcoded default, it is this token. card/popover/surface are the container fills; radius-widget/radius-lg the roundings; ring the focus outline; window-shadow-* the floating-window shadow. The full list with what each drives is in the ZuiThemeTokens type.

So there are three levels, outermost to innermost: theme tokens (global chrome) → theme styles (per-class) → inline style (per-instance). Each overrides the one before it for the widgets it touches.

Styling the dock

Z.dockArea is styled entirely from its style block, typed as ZuiStyle & ZuiDockStyle. The standard keys (borderWidth, borderRadius, padding) shape the tab-body and main-surface chrome; the ZuiDockStyle keys cover the dock-specific surfaces the global theme does not carry:

Z.dockArea({
    style = {
        borderWidth = 0,                  -- no main-surface border
        tabBarBackground = "#16181d",     -- tab bar fill
        tabBackgroundActive = "#1e1e1e",  -- active tab fill (match the body)
        tabTextColorActive = "#d6dae2",   -- active tab label
        tabOutline = "transparent",       -- no per-tab outline
        tabBarHairline = "transparent",   -- no line under the tab bar
        tabUnderline = false,             -- no underline on the active tab
        separatorColor = "#2a2f3a",       -- split divider
    },
    children = panels,
})

The dock inherits the global theme Visuals automatically (tab/body fills follow the active theme), so reach for these keys only to override that inheritance — e.g. to make the tab bar flush with the body, or to recolor a specific dock. Every key is in ZuiDockStyle; a misspelling is an LSP error, not a silent no-op.

What is NOT styleable today

The honest list. These cannot be changed from a style block OR a theme token today. If one of these blocks you, it's a feature request, not a missing key:

  • Per-instance, not in any style block: text-selection/cursor colors, hover-fade timing, the modal backdrop dim, table striping colors, grid cell spacing, split-view resize-handle look, window title bars. (Many of these ARE settable globally through theme tokens — they're just not per-widget.)
  • Whole widgets: datePicker and colorPicker (pure egui chrome), table (forced striped + resizable, fixed header height), scene.
  • Sub-elements: the heading + separator a title prop renders, code-editor fold-gutter colors and line numbers, button icon-to-text gap, collapsible arrow glyphs and hover highlight, popup inner padding, the floating dock-window default size.
  • Dead keys: opacity, zIndex, and style-level visible are not consumed by any widget (visibility is the visible prop). Excluded from ZuiStyle for that reason.
  • Range limits: borderRadius truncates above 255; padding/margin above 127 px overflow.

Note what is NOT on this list anymore: the box outline on every container (border token), window/panel fills (card/popover), scrollbar width, window shadow, and the full dock tab chrome are all reachable — see the theming and dock sections above.

Where to look things up

  1. ZuiStyle — per-widget inline style keys.
  2. ZuiThemeTokens — the global theme tokens (the chrome on every box).
  3. ZuiDockStyle — dock tab/bar/separator chrome. (All three in @builtin::modules.zui.style; your editor autocompletes them.)
  4. This guide — value shapes, per-widget applicability, the unsupported list.
  5. The widget's own module — per-widget extras on its exported Opts type.

If a key isn't visible in those places, it doesn't exist.