Image
The node that shows a picture. Its file travels inside the envelope, so the game never fetches anything, and its natural size is the source's own.
primitivesince v1not focusable
An Image draws a picture inside a rectangle. You reach for one for icons,
portraits and banners: the coin beside a price, the item art in an inventory
slot, the header of a shop panel. The file is not fetched at runtime — zabloo export reads it off disk and packs its bytes into the
envelope, so what the game receives is a picture it already
holds. The node takes no children, because it is a rect with pixels in it and
everything else is layout.

- hover — off
- pressed — off
- focused — off
- selected — off
- disabled — off
Reported from the last painted frame
Not running — press Run to draw this on the GPU.
import { Column, Image, Row, Text } from "@zabloo/react";
import type { ReactNode } from "react";
const BOX = { width: 132, height: 84 } as const;
const FRAME = {
background: "{color.slot}",
radius: "{radius.md}",
borderWidth: "{border.hairline}",
borderColor: "{color.line}",
} as const;
function Sample({ caption, children }: { caption: string; children: ReactNode }) {
return (
<Column layout={{ gap: "{space.2}", align: "center" }}>
{children}
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>{caption}</Text>
</Column>
);
}
export default function ImageFit() {
return (
<Column
layout={{ grow: 1, justify: "center", align: "center", padding: "{space.6}" }}
style={{ background: "{color.bg}" }}
>
<Row layout={{ gap: "{space.3}", align: "start" }}>
<Sample caption="contain">
{/* The whole image, undistorted, letterboxed in the box. */}
<Image src="icons/star.png" fit="contain" layout={BOX} style={FRAME} />
</Sample>
<Sample caption="cover">
{/* Fills the rect, undistorted, cropping the overflowing axis. */}
<Image src="icons/star.png" fit="cover" layout={BOX} style={FRAME} />
</Sample>
<Sample caption="stretch">
{/* Fills it exactly, aspect ratio and all. */}
<Image src="icons/star.png" fit="stretch" layout={BOX} style={FRAME} />
</Sample>
<Sample caption="style.color">
{/* The tint multiplies per channel — absent = white = the pixels as
they are, so this needs no second image. */}
<Image
src="icons/star.png"
fit="contain"
layout={BOX}
style={{ ...FRAME, color: "{color.gold}" }}
/>
</Sample>
</Row>
</Column>
);
}{
"v": 1,
"tokens": {
"color.bg": "#0b0d13",
"color.surface": "#0e1016",
"color.raised": "#14141a",
"color.line": "#ffffff1f",
"color.line-strong": "#ffffff24",
"color.text": "#ffffff",
"color.muted": "#a1a1aa",
"color.faint": "#8a8a93",
"color.on-brand": "#ffffff",
"color.brand": "#8b5cf6",
"color.brand-strong": "#7c3aed",
"color.brand-hover": "#8b5cf6",
"color.brand-pressed": "#6d28d9",
"color.brand-soft": "#8b5cf61f",
"color.gold": "#fcd34d",
"color.slot": "#ffffff08",
"color.danger": "#f87171",
"radius.sm": 6,
"radius.md": 10,
"radius.lg": 14,
"radius.pill": 999,
"border.hairline": 1,
"border.focus": 2,
"space.1": 4,
"space.2": 8,
"space.3": 12,
"space.4": 16,
"space.5": 20,
"space.6": 24,
"text.xs": 11,
"text.sm": 13,
"text.md": 15,
"text.lg": 17,
"motion.fast": 120,
"motion.loop": 900
},
"views": {
"image-fit": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "center",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.3}",
"align": "start"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "center"
},
"children": [
{
"type": "Image",
"layout": {
"width": 132,
"height": 84
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"src": "asset:icons/star.png",
"fit": "contain"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "contain"
}
]
},
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "center"
},
"children": [
{
"type": "Image",
"layout": {
"width": 132,
"height": 84
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"src": "asset:icons/star.png",
"fit": "cover"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "cover"
}
]
},
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "center"
},
"children": [
{
"type": "Image",
"layout": {
"width": 132,
"height": 84
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"src": "asset:icons/star.png",
"fit": "stretch"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "stretch"
}
]
},
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "center"
},
"children": [
{
"type": "Image",
"layout": {
"width": 132,
"height": 84
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}",
"color": "{color.gold}"
},
"src": "asset:icons/star.png",
"fit": "contain"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "style.color"
}
]
}
]
}
]
}
},
"assets": {
"icons/star.png": {
"hash": "02f69f8ed48938f7704d264cd732afec0762fd8fd654944876443c87ed3cb1bd",
"mime": "image/png",
"size": 689,
"width": 64,
"height": 64,
"data": "iVBORw0KGgoA… (689 bytes, base64)"
}
}
}The two tables below are the clearest case of the two layers being genuinely
different things. The prop you write is a path in your project; the prop that
ships is a reference into the envelope, rewritten by zabloo export when it
gathered the bytes. Same name, two meanings, and only one of them ever reaches a
player’s machine.
Authoring props
What you write in @zabloo/react.
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | — | Path inside the project's src/assets/ — "logo.png", "icons/coin.png". |
| fit | "contain" | "cover" | "stretch" | "contain" | How the source fills the layout rect. |
On top of these, every component takes the node base props — id, visible,
disabled, layout, style, states, transition, autofocus, clip —
plus variant, which the theme resolves away at export time and which never
appears in the IR.
IR props
What ships to the game, after the authoring layer is gone.
| Prop | Type | Default | Description |
|---|---|---|---|
| src | AssetRef | — | "asset:<id>" — an entry in the envelope's manifest. Never a binding. |
| fit | "contain" | "cover" | "stretch" | "contain" | How the source fills the layout rect. |
An asset reference is collected at export time, when the bytes are gathered into the envelope — so there is nothing for a binding to point at at runtime. A per-row icon chosen by data is not expressible in IR v1; a view that needs one authors its slots statically, or the game swaps a whole view.
Fit
Every mode paints inside the rect. cover crops the source through its UVs
rather than overflowing, so the invariant that makes hit-testing on layout rects
honest holds without any clipping machinery.
| Mode | Result |
|---|---|
"contain" | The whole image, undistorted, centered — letterboxed. |
"cover" | Fills the rect, undistorted, cropping the overflowing axis evenly. |
"stretch" | Fills the rect exactly, distorting the aspect ratio. |
Paint
There are no Image-specific style props. Everything is the ordinary style
set, which is what makes an icon themeable and per-state for free:
style.colortints the image, multiplied per channel. Absent = white = the pixels as they are. It is the same “color of this node’s content” that colors glyphs, sostates.*.style.colortints per state with nothing new.style.radiusrounds the painted image, matching the node’s own background.style.backgroundandborderWidthare the placeholder. An image paints nothing until its bytes are decoded, and the layout has already reserved the space from the manifest’swidth/height.
There is no loading state: the placeholder is authored, not a runtime
state the SDK enters.
Behavior
- States
disabledonly, inherited — an icon greys out with the control it belongs to. Inside aButton, the button’s own states can tint it: the style is the button’s, the image is its content, so a hover lights up both without the image declaring anything.- Focusable
No. An image takes no input and nothing the player does reaches it, so nothing beyond
disabledapplies.- Sizing
Intrinsic, from the manifest’s
width/height— a leaf with a natural size, likeText. Give itlayout.width/heightto size it yourself, andfitto choose how the picture fills that box.- Actions
None, so the game hears nothing from an
Image. A picture that reacts to a press is anImageinside aButton, and the press belongs to the button.
Degradation
On an older SDK the box keeps its exact size and whatever background you gave it — the picture never appears inside it.
As an empty Container: it is a leaf, so what survives is its box, its background and its border — the placeholder you authored, permanently. Nothing about the layout moves, because the space was reserved from the manifest before the bytes were ever decoded.
Composition
An icon before a label is a Row; an icon-only button is a Button whose only
child is an Image. The node has no opinion about either.
- Intrinsic size — use it when the art was drawn at the size it should appear, which is the normal case for icons.
- Sized and cropped — use
fit="cover"with an explicit box for banners and portraits, where the slot’s shape matters more than the whole picture. - A tinted monochrome source — use it when the same shape needs several colours: one file in the envelope, every colour the theme has, and a state change costs no second asset.
// Intrinsic size, straight from the manifest.
<Image src="icons/coin.png" />
// Sized and cropped by you.
<Image src="banners/shop.png" fit="cover" layout={{ width: 320, height: 120 }} style={{ radius: 8 }} />
// Tinted per state: the same "color of this node's content" that colors glyphs.
<Image
src="icons/star.png"
layout={{ width: 20, height: 20 }}
style={{ color: "{color.faint}" }}
states={{ hover: { style: { color: "{color.gold}" } } }}
/>