zgx

Properties

What every element takes besides its children and its own options. A property is a value, a state holding one, a function returning one (run again when the state it reads changes), or a binding: Reactive<T>. Literal values apply once; anything that can change shares one reactive style per node.

-- Each style property, as elements take it:
w: Reactive<Length>?
bg: Reactive<Color>?
-- …and as `hover`, `active`, `focus`, `disabled` and `context_menu_style` take it:
hover: Styles?

focus is CSS’s :focus-visible: it applies when focus came from the keyboard (or a script, before any click), on a text field however it got focus, and once a key goes to the focused node; a clicked button takes focus without it.

The types: Color is "#rgb", "#rrggbb", "#rrggbbaa", "rgb(…)", "rgba(…)", "hsl(…)", a few names, or a number 0xrrggbb. Length is points or a string: "50%" (of the containing block), "12px", and "auto" for margins. Pair is { a, b } or one number for both. Unless a row says otherwise a property has no default: the node has none of it.

Size and position

PropertyTypeDefault
w, hLengthits content’s sizeborder-box: the border and padding are inside
sizePairw and h at once
min_w, max_w, min_h, max_hLength
w_full, h_fullbooleanfalsew = "100%", h = "100%"
aspectnumberwidth over height; supplies the missing side
absolutebooleanfalseout of the flow, placed by insets (see Position)
relativebooleanfalsea containing block for absolute descendants
top, left, right, bottomLengthinsets of an absolute node
inset_start, inset_endLengthits inset from the start or end of a line (CSS’s inset-inline-start/-end): left and right left to right, right and left with dir = "rtl"
dir"ltr" | "rtl"its parent’s ("ltr" at the root)which way lines run in it and what it holds (HTML’s dir): "rtl" runs rows from the right, starts a column’s children and text at the right, and turns the ps/pe, ms/me, inset_start/inset_end edges round; pl, ml and left stay on the left

Spacing

PropertyTypeDefault
p, px, py, pt, pr, pb, plLength0 (a button: px = 12, py = 8)padding; later keys refine earlier ones, a percentage counts from the containing block’s width
ps, peLengthpadding at the start and end of a line (CSS’s padding-inline-start/-end), which follow dir
m, mx, my, mt, mr, mb, mlLength0margins; "auto" takes the remaining space
ms, meLengthmargins at the start and end of a line (margin-inline-start/-end), which follow dir
gapnumber0 (a button: 6)between children
gap_x, gap_yLengthper axis (a grid’s or a wrapping row’s)

Flex and grid

PropertyTypeDefault
direction"row" | "row_reverse" | "column" | "column_reverse"column (row in a row)
grownumber0share of the free space along the parent’s axis
shrinknumber1share of an overflow given up; 0 means it never shrinks. It shrinks no further than its content needs unless min_w/min_h say otherwise
basisLengthits size
flex_wrap, wrap_reversebooleanfalsewrap children onto lines
items"start" | "center" | "end" | "baseline" | "stretch""stretch"children across the axis: stretched to the container’s width (a column’s) or height (a row’s) unless they size themselves there, as in CSS
justify"start" | "center" | "end" | "between" | "around" | "evenly" | "stretch""start"children along it
self"start" | "center" | "end" | "baseline" | "stretch"the parent’s itemsthis child across the parent’s axis
content"start" | "center" | "end" | "stretch" | "between" | "around" | "evenly""start"wrapped lines
gridbooleanfalselay children out in a grid of equal tracks
cols, rowsnumberthe grid’s equal tracks
col_start, row_startnumberthe next free cellone-based grid lines; negative count from the end
col_span, row_spannumber1
col_span_full, row_span_fullbooleanfalsespan every track
overflow, overflow_x, overflow_y"visible" | "hidden" | "clip""visible" (scroll views: "hidden")"hidden" and "clip" clip paint and hit testing
hiddenbooleanfalseout of layout and input, keeping its state
invisiblebooleanfalsenot drawn, keeping its space

Paint

PropertyTypeDefault
bgColornone (button, input, slider, progress: #232e40)
gradientGradient{ "#f80", "#08f" } or { angle = 135, stops = { … } }; stops are colors (evenly spaced) or { offset, color }; CSS angles: 0 runs bottom to top, 90 left to right, 180 (the default) top to bottom
colorColor#e5edf7text, and a progress bar’s fill and a slider’s thumb; inherited
roundednumber0 (controls: 6)corner radius
cornersCorners{ tl, tr, br, bl }, as a list or keyed
rounded_tl, rounded_tr, rounded_br, rounded_blnumberone corner, refining rounded or corners
bordernumber0width, inside the box
border_sidesSides{ top, right, bottom, left } widths: a dock open at the bottom is { 1, 1, 0, 1 }
border_colorColor
border_overbooleanfalsepaint the border over the children, taking no room (a ring)
border_dashPairsolid{ length, gap }
shadowShadownone{ x, y, blur, spread, color } (CSS box-shadow; color #0000005a by default), a list of them, or false
outline, outline_color, outline_offsetnumber, Color, numbernone, black, 0an outline around the box (CSS’s): its width, colour, and where it starts outside the box; a negative offset puts it over the edge (an inset focus ring is outline = 1, outline_offset = -1)
inset_shadowShadownoneshadows inside the box (CSS’s inset), over its fill and under its border and children: the box less itself moved by x, y and shrunk by spread, cut to its corners; drawn sharp (blur is not applied). A 1-point top highlight is { x = 0, y = 1, color = "#ffffff1f" }
opacitynumber10 to 1
brightness, contrast, saturatenumber1CSS filter functions over the node and its subtree
blurnumber0blurs the node’s content (CSS filter: blur())
backdrop_blurnumber0blurs what is behind it: frosted glass
fadePaira clipping view’s top and bottom edges fade over those widths
fade_xPairits left and right edges
fade_sidesSideseach edge: { top, right, bottom, left }
layerbooleanfalsepaint the subtree into its own layer
fit"fill" | "contain" | "cover" | "none" | "scale_down""fill"how an image fills its box
tintColorrecolors an SVG (a state or function re-tints it)
dithernumberdraws an image dithered in dots this many points square (see Images and canvas)
cursorCursor"default""pointer", "text", "vertical_text", "crosshair", "move", "grab", "grabbing", "not_allowed", "wait", "progress", "help", "context_menu", "copy", "alias", "no_drop", "all_scroll", "col_resize", "row_resize", "ew_resize", "ns_resize", "nesw_resize", "nwse_resize", "zoom_in", "zoom_out"; CSS’s spellings ("nwse-resize") too

Background images, masks, shader fills, clip insets

PropertyTypeDefault
bg_imagestringa PNG or JPEG path (relative to the script), state or function: drawn over bg, under the border and children, cut by the corners
bg_fit"cover" | "contain" | "fill" | "none" | "scale_down""cover"a literal only
maskBitmap | { bitmap, w, h, x, y, sweep }the content shows through ui.bitmap’s alpha, drawn at w×h (the bitmap’s size) from (x, y); animate maskX, maskY, or sweep = { x, y, duration, frame_rate } to have the engine move it by (x, y) every duration ms, over and over, with no script per step
bg_shaderShaderui.shader(wgsl, { fps }) fills the box per pixel (see Images and canvas)
shader_params{ Color | { number } }zerosup to four vec4s: colors (0 to 1) or lists of numbers; animate shaderTime, shaderParam0 to shaderParam15
clip_insetSidespaints only inside the box shrunk by { top, right, bottom, left } (CSS clip-path: inset()); animate clipTop, clipRight, clipBottom, clipLeft
clip_feathernumber0where it clips (overflow = "hidden"), its content fades out over this many points inside its rounded edge rather than being cut there, in the fragment shader quads already run (no layer, no mask). Its corners follow its clip inset, held to half the inset box: with rounded = 9999 and the clip inset animated from the middle out, a soft circle opens, compositing alone

Size and position animation

PropertyTypeDefault
layout"height" | "width" | "size" | "position" | booleannone"height" (or true), "width", "size": the space the node takes eases to its new size, while it lays out at that size at once; "position": it slides when layout moves it
layout_idstringa node mounting while (or as) another with the same id is mounted starts at that one’s box and moves to its own; ids are per script
layout_easeEasinga spring, 0.4 s, bounce 0.1
layout_durationnumberthe ease’sms
layout_enterbooleanfalsegrow from nothing when it mounts
layout_clipbooleanfalseclip the content to the animated space

Dynamic content covers each’s animate, which slides rows the same way.

Text

PropertyTypeDefault
text_sizenumber16inherited, as all of these
weightnumber400bold = true is 700
bold, italicbooleanfalse
align"start" | "left" | "center" | "right""start"
fontstringthe system’s"mono" ("monospace"), "sans" ("sans-serif"), "serif", or a family name the host registered
wrapbooleanfalsewrap at the box’s width
truncatebooleanfalseone line ending in ”…”
linesnumberallclamp to that many lines
line_heightnumberthe font’spoints
letter_spacingnumber0points
tabularbooleanfalsetabular figures: every digit one width, so a changing number holds still
underlinebooleanfalseunderlined text, inherited (CSS’s text-decoration): hover = { underline = true } underlines a link button’s label
underline_offsetnumbera tenth of the text sizepoints below the baseline the underline sits; inherited

Transforms

x, y (number, points), scale, scale_x, scale_y (number, 1), rotate, skew, skew_x, skew_y (number, degrees) and origin (Pair, fractions of the node, { 0.5, 0.5 }). They apply after layout: the node keeps its place in the flow, and hit testing follows the transform. Rotation, scale and skew paint the subtree into a cached layer.

Pseudo-states, window dragging, identity

PropertyTypeDefault
hover, active, focusStyles(controls: a hover color and an accent focus ring)styles while the pointer is over it, while it is pressed, while it has keyboard focus; focus = { border = 0 } turns a control’s ring off
disabledboolean | state | function | Stylesfalsea condition disables the node (and what is inside); a table is the styles it shows while disabled
selectablebooleanfalsethe pointer selects the text in it and in everything it holds, across nodes, as on a web page (CSS user-select: text); false keeps a part of a selectable node out. See selectable text
selection_colorColor | Readable<Color> | (() -> Color)$selectiona selectable node’s highlight (CSS ::selection { background }), in either window state
selected_text_colorColor | Readable<Color> | (() -> Color)$selection.textthe selected glyphs’ colour (CSS ::selection { color }); transparent keeps their own
window_dragbooleanfalsea press on it, or on what inside takes no input, moves the window; a double click zooms it (CSS -webkit-app-region: drag)
tooltipstring or a nodenoneshown over it while the pointer rests on it: after 300 ms, at once within a second of another tooltip; gone when the pointer leaves or presses, or a key is pressed anywhere (as a browser’s and the system’s do). Text takes the theme’s look (an overlay card, small text); a node is the app’s own content. It fades in over 140 ms and out over 100 ms, at once when it takes over from another; one shows at a time. It takes no focus and lets the pointer through
provide{ [string]: any }nonevalues the components and rows built in its subtree read with ui.use_context(name), as React’s context gives them (components)
tooltip_placementPlacementabove, centred, 6 points offwhere the tooltip sits (as a popover’s placement)
tooltip_delaynumber300milliseconds the pointer rests before it shows. Keyboard focus shows it too (not a click’s), and a text tip describes the node to assistive technology, which doesn’t see the tip itself
tooltip_interactivebooleanfalsea hover card the pointer can move into and click in: it stays while the pointer is over the node or the card (a moment’s grace to cross between them); typing goes to its fields, and Escape closes it
idstringfor selectors ("#id") and the inspector
classstringspace-separated, for selectors (".name")
refRef | { Ref }see State
focusablebooleancontrols: truetakes keyboard focus
pointer_events"none", "auto", boolean, or a state or function of one"auto""none" or false lets the pointer pass through to what is under the node (presses, hover, the wheel, drops, and a web view or terminal, even where the node paints over it); descendants with their own handlers still receive input

Accessibility

What assistive technology (VoiceOver, Narrator, Orca) reads for a node beyond what it is: controls say their role, name and state themselves, and these say what a plain box means. They cost nothing on nodes that don’t set them. A label given as a state or function costs nothing either while no screen reader is on: it is worked out only while assistive technology reads the window (from the state as it is, when one starts), not each time what it reads changes.

PropertyTypeDefault
labelstring | state | functionits textthe name read for a box (ARIA’s aria-label): an icon button, a group. A state or function is followed, for a recycled list row’s name made from what it shows now ("Unread, Grace Hopper, Lunch?, Oct 28"). Widgets take their own label
roleAriaRole: "group", "button", "link", "heading", "list", "listitem", "listbox", "option", "toolbar", "status", "tablist", "tab", "tabpanel", "article" (a message, a post), "dialog", "img", "radiogroup", "radio", "switch", "checkbox", "spinbutton", "combobox", "tree", "treeitem", "grid", "row", "gridcell", "columnheader", "alert", "menu", "menuitem"what it iswhat it is taken for (ARIA’s role), keeping what it does: a clickable row as an "option" in a "listbox", a row of buttons as a "toolbar". Options, tabs, list items and headings are named by their text
checked, expandedboolean | Readable<boolean> | (() -> boolean)nonewhat a control of the app’s own says of its state: a switch’s or a radio’s checked (aria-checked, a checked radio is where Tab lands in its group), what it opens expanded (aria-expanded)
headingnumbera section title of that level, 1 to 6 (h1…h6): screen readers list headings and move between them (VoiceOver’s rotor)
selectedboolean | state | functionwhether an option, tab or item is the chosen one (aria-selected), followed
live"polite" | "assertive" | "off""off" (role = "status": "polite")announce changes to the text in it (aria-live): a count, a “Sent” note, a message arriving; "assertive" interrupts
descriptionstringread after the name, as a hint (aria-description)
errorstring | state | functionwhat is wrong with its value, followed; nil while fine: assistive technology hears it invalid (aria-invalid), and the message after its description. field gives a form’s
aria_hiddenbooleanfalseleaves it and what it holds out of the accessibility tree (aria-hidden): decoration such as an unread dot, or what is said elsewhere
focus_group"vertical" | "horizontal" | "both"role = "listbox": "vertical"; "toolbar", "tablist": "horizontal"one Tab stop for the focusable things inside, as ARIA’s composite widgets are: Tab lands on the one last focused there (else the selected one, else the first), and the arrows along the axis, Home and End move among them. A field inside keeps its own arrows
local mailbox = ui.state("mailbox", "Inbox")
return ui.column {
	role = "listbox",
	label = "Mailboxes",
	ui.row {
		role = "option",
		selected = function()
			return mailbox:get() == "Inbox"
		end,
		on_click = ui.set(mailbox, "Inbox"),
		ui.text { "●", aria_hidden = true },
		ui.text { "Inbox" },
	},
}

Position

An absolute = true node leaves the flow and positions against its containing block: the nearest ancestor with relative = true, searched from its parent up but not past an absolute ancestor (which contains its own absolute descendants); with none, its parent. left, top, right and bottom count from that block’s padding box, inside its border; percentages are of its size; both insets on an axis without a size stretch the node between them; an axis without insets keeps it at its parent’s content origin (plus ml/mt). show, switch, each and presence wrap their content in a node of their own, so mark the ancestor you mean relative rather than relying on being its direct child. Transforms and scroll offsets never change the containing block: an absolute node moves with them, and still paints in its parent’s place, under its clips.

Example

local liked = ui.state("liked", false)
return ui.column {
	p = 24,
	ui.column {
		relative = true,
		w = 280,
		p = 16,
		gap = 8,
		rounded = 14,
		gradient = { angle = 135, stops = { "#232e40", "#1a2230" } },
		border = 1,
		border_color = "#ffffff1a",
		shadow = { y = 8, blur = 24, color = "#00000066" },
		ui.text { "Ada Lovelace", text_size = 18, weight = 600 },
		ui.text {
			"Wrote the first program for a machine that did not yet exist.",
			wrap = true,
			lines = 2,
			color = "#8b95a5",
			line_height = 20,
		},
		ui.button {
			self = "end",
			rounded = 999,
			bg = function()
				return if liked:get() then "#e5534b" else "#2f4058"
			end,
			hover = { brightness = 1.15 },
			active = { scale = 0.96 },
			on_click = ui.toggle(liked),
			"Like",
		},
		-- Pinned to the card's corner, whatever comes before it.
		ui.div { absolute = true, top = 10, right = 10, w = 8, h = 8, rounded = 4, bg = "#3fb950" },
	},
}

Gotchas

  • A state or function whose value does not fit the property (a string for w that is not a length, a boolean for bg) is ignored at run time; a literal that does not fit is an error. In the editor both are type errors.
  • hover, active, focus, disabled and context_menu_style take plain values: a state, function or binding in them is an error. To animate on hover, animate the base style from on_hover.
  • Children stretch across their container (items = "stretch") and shrink in one too small for them (shrink = 1), as CSS’s flexbox; text does not wrap (wrap = false) unless asked. A child that shrinks stops at its content’s narrowest (CSS’s min-width: auto: a text’s longest word), unless it clips (overflow, a scroll) or sets min_w = 0; shrink = 0 holds it.
  • gap is points only; gap_x and gap_y take percentages.
  • A transparent border still takes its width; a ring that must not move content is border_over = true.
  • bg_fit and the shape of mask, bg_shader and shader_params are read once, as literals.
Edit on GitHub