Skip to content
zabloo

Style & tokens

How a node paints: the style set, resolved per node with no cascade and no inheritance — plus the runtime states and the normative order they merge in.

Style is how a node paints: its fill, its corner radius, its border, the size and colour of its text. The shop’s Buy button is a box with a purple background and a 6 px radius, and it goes a shade lighter under the pointer — that is two style declarations, one for the button and one for the state it is in. Colours and sizes are almost never spelled out; they point at tokens, which is what lets one dictionary re-theme a whole game.

Two rules shape everything below.

Style is resolved per node. There is no cascade and no inheritance: a node’s style is the whole answer to how it looks, and the SDK never walks up the tree to compute it. Nothing a parent declares reaches a child. The one exception is opacity, which multiplies down a subtree — and it is an exception on purpose.

Paint is implicit: there is no draw-command layer in v1. You do not describe shapes, you describe a node, and its style implies what gets drawn — a rounded rectangle, an inset stroke, and the node’s own content on top. An explicit paint layer, with paths and arcs, is a compatible future extension.

The style set

PropTypeDefaultDescription
backgroundColorValuenoneFill of the node's rect. Absent = nothing is painted.
radiusDim0Corner radius of the fill, the border and the clip.
borderWidthDim0Stroke thickness, inset.
borderColorColorValueabsentStroke color.
colorColorValuewhiteColor of the node's content — glyphs, image tint, caret and selection.
fontSizeDim16Text size in px, rounded and clamped to 1..512.
opacitynumber10..1, clamped. Multiplies down the subtree.
textAlign"start" | "center" | "end""start"Horizontal alignment of each line inside the rect.
textAlignY"start" | "center" | "end""start"Vertical alignment of the whole text block.
lineHeightDimthe font's metricDistance between the tops of consecutive lines.
wrapbooleantrueWord-wrap text to the available width.
overflow"clip" | "ellipsis""clip"How text that does not fit is cut.
maxLinesnumberunboundedCap on the number of lines.

The text properties live in style, next to fontSize and color, so they are themeable through tokens and overridable per state like every other visual input. They are read from a Text node and ignored elsewhere; their exact semantics, including the normative wrap algorithm, are on that page.

Text size

fontSize is resolved per frame — through tokens, through states, through a transition — and then rounded and clamped to 1..512 before a glyph is asked for. The ceiling is normative, not an implementation detail of one renderer: rasterization cost grows with the square of the point size, so an unclamped fontSize — an animated token overshooting, a value that arrives from data — is the difference between a big headline and a hundreds-of-megabytes glyph bitmap. Clamping silently is deliberate: the value is re-resolved on every frame, so there is no single moment at which to report it.

Border

borderWidth grows inward. The stroke is painted inside the layout rect, border-box style, so a node with a border occupies exactly the same space as one without. This is what keeps the invariant that nothing paints outside its own rect, and it is why a focus ring made of a border never shifts the layout when it appears.

The fill and the border share one parameterization of the rounded perimeter, so they meet without a seam at any radius.

Content color

color is “the color of this node’s content”, and each node type has content of its own: a Text’s glyphs, an Image’s tint (multiplied per channel; absent = the pixels as they are), a TextInput’s glyphs plus its caret and selection highlight. A Container has no content of its own, so color does nothing on one.

Opacity

opacity is multiplicative down the subtree: a node at 0.5 inside a parent at 0.5 paints at 0.25. It is applied as per-vertex alpha, not as group opacity through a render target — overlapping children inside a faded subtree show through each other rather than compositing as one flattened layer.

States

A control looks different depending on what is happening to it: the Buy button is one colour at rest, another under the pointer, another while a finger is down on it. Those are its states, and a node declares a style override for each one it cares about. The SDK owns the states themselves, keyed by component identity: the IR declares what a state looks like, never when it happens.

{
  "type": "Button",
  "style":  { "background": "{color.primary}" },
  "states": {
    "hover":   { "style": { "background": "{color.primary.hover}" } },
    "focused": { "style": { "borderWidth": "{border.focus}" } },
    "pressed": { "style": { "background": "{color.primary.pressed}" } }
  }
}

There are seven states and no way to invent an eighth, because the SDK is what raises them. Which node types can be in which is fixed, and it is fixed the same way on every target — so this is the list of names a style override can key off:

normativeThe states

StateCarried byMeaning
emptyTextInputThe field holds no text. This is what styles a placeholder.
selectedA button of an "exclusive-select" groupIt is the chosen tab.
checkedToggleIt is on — its own value, or the one its "exclusive-check" group derives.
hoverFocusable nodesThe pointer is over it.
focusedFocusable nodesIt holds the focus.
pressedButton, Toggle, Slider, a Collapse's headerA finger or button is down on it. A Slider carries it while the pointer drags it; a Collapse header only from the keyboard or the pad, which is the only way it is pressed.
disabledEvery nodeIt — or an ancestor — declares disabled, so it is out of the interaction model.

hover lights up exactly the focusable set — what takes input is what may look different under the pointer — so a plain Container is never hovered.

disabled is the one state a node that is not focusable can be in, because it is the only one that inherits: a disabled section hands it to everything inside, and the labels of that section have to be able to dim with the controls, or switching the section off would only reach half of what the player sees.

The merge order

A control is often in several states at once, and they will disagree about the same colour. This is the rule that decides which one you see — and every SDK resolves it identically, which is why it is normative.

normative

States overlap: a pressed button is usually also hovered and focused. They are merged in one fixed order, least to most specific, later winning field by field:

base → empty → selected → checked → hover → focused → pressed → disabled

Worked example — pressing Buy in the shop. The player moves the pointer over the button and holds the mouse down, so hover, focused and pressed are all true at once. Take the node above: background is declared by the base, by hover and by pressed, and pressed is the last of the three in the order, so the button paints {color.primary.pressed}. borderWidth is declared only by focused, and nothing after it mentions that field, so the focus ring stays on over the pressed fill. The states merge field by field, not one instead of another.

The value states come first — what the control is is the baseline — and the transient interaction states paint over them. empty opens the list because it is the weakest thing a control says about its value: a placeholder color must lose to anything the author says about a selected or focused field. hover sits under focused so a mouse passing by never hides a focus ring, and pressed wins over those because it lasts exactly as long as the finger is down.

disabled closes the list, and its place there only matters against the value states: a disabled node takes no input at all, so hover, focused and pressed can never be active alongside it, while a disabled Toggle is still checked and a disabled field still empty. Coming last is what lets one override speak for the whole control, whatever value it happens to be holding.

Merging is per field, and only within style. A state override never replaces the base style wholesale, and it cannot change layout, children or behavior; a state that declares nothing about a field leaves whatever the layers below it resolved.

Variants are authoring only

variant is a @zabloo/react concept and never reaches the IR. A theme defines named style sets per component:

export const theme = {
variants: {
  Button: {
    primary: {
      style:  { background: "{color.primary}", radius: "{radius.md}" },
      states: { hover: { style: { background: "{color.primary.hover}" } } },
    },
  },
},
};

At export time the variant’s style and states are merged under the node’s explicit props — explicit always wins — and the envelope receives the node fully resolved. An unknown variant fails loudly during authoring, which is the right moment for it.

The theme also carries default motion per component (transitions), resolved the same way: node prop, then variant, then theme default — and the most specific one wins whole rather than field by field.