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.5is half the parent,width = 320is 320 px. The bounds are exclusive:width = 1is 1 px (not full width) andwidth = 0is 0 px — use the"100%"/"50%"string when you mean a percentage and want no ambiguity. padding/margintake a number (all sides),{x, y}(horizontal/vertical pair), or{top, right, bottom, left}. Per-sidemarginTop/Right/Bottom/Leftoverride the shorthand.shadowis 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 explicitshadowwins.- 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, theflex*family,position+ insets). Panels additionally takebackgroundImage(+backgroundFit/backgroundSlice),gradient, andbackgroundShader— 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 frombackgroundHover,backgroundActive,colorHover. - Value widgets:
Z.sliderreadsbackground(track) andcolor(fill/grab);Z.checkboxreadscolor(check mark),background(box) andborderColor.Z.btnalone addsiconColor,suffixColor,suffixSizefor its icon/suffix decorations. - Media leaves (
Z.image,Z.viewport) honorwidth/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.imagefalls back to a 64 px icon andZ.viewportfills its slot, so an unsized image stays small rather than ballooning to the texture's native pixels.Z.imageadditionally maps the texture into that box viabackgroundFit(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 overZ.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.windowandZ.modalonly apply their frame styling oncebackgroundis set. Without it they use the default chrome and ignoreborderRadius/borderColor/borderWidth.- A handful of widgets have no styleable surface at all:
Z.datePicker,Z.colorPicker,Z.table, andZ.scenerender entirely from the global theme, so theirstyleblock is accepted but unused today.Z.spaceris invisible too, but it is a layout strut: it consumeswidth/height(or thespaceprop) 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 bybackgroundSlice: 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.
-
stylesmaps widget classes to style tables (["button.primary"] = { background = "$accent" }) with$tokenindirection — the cascade applies them to every widget carrying that class. The vocabulary is the sameZuiStyleschema. -
tokensis 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 asZuiThemeTokens(@builtin::modules.zui.style).The single most useful fact:
border+border-widthare the outline you see on every box. A theme withborder = "transparent"(orborder-width = "0") flattens the whole UI in one move — that outline is not a hardcoded default, it is this token.card/popover/surfaceare the container fills;radius-widget/radius-lgthe roundings;ringthe focus outline;window-shadow-*the floating-window shadow. The full list with what each drives is in theZuiThemeTokenstype.
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:
datePickerandcolorPicker(pure egui chrome),table(forced striped + resizable, fixed header height),scene. - Sub-elements: the heading + separator a
titleprop 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-levelvisibleare not consumed by any widget (visibility is thevisibleprop). Excluded fromZuiStylefor that reason. - Range limits:
borderRadiustruncates above 255;padding/marginabove 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
ZuiStyle— per-widget inline style keys.ZuiThemeTokens— the global theme tokens (the chrome on every box).ZuiDockStyle— dock tab/bar/separator chrome. (All three in@builtin::modules.zui.style; your editor autocompletes them.)- This guide — value shapes, per-widget applicability, the unsupported list.
- 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.