Repeat
The node that turns one row into many. You author a single template, bind an array the game owns, and the SDK builds one copy per element.
primitivesince v1not focusable
A Repeat lets you write a row once and show it as many times as the data
says. You give it a data path to an array the game owns
and a single template node; the SDK builds one copy of
that template per element and points each copy’s bindings
at its own item. Picture a quest log: the document that ships carries one
quest row, and the seven the player sees are that row instantiated against
quests.open. You reach for it whenever how many things appear is the game’s
business rather than the screen’s.
Viewport: 960 × 340

- 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 { Button, Column, List, Row, Text } from "@zabloo/react";
export default function RepeatList() {
return (
<Column
// `align: "stretch"` on the ROOT is what hands the panel the view's own
// width; centring it instead would pin the panel to its content and no
// change of viewport could reach it (ZAB-153).
layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
style={{ background: "{color.bg}" }}
>
<Column
id="panel"
layout={{ padding: "{space.5}", gap: "{space.4}", align: "stretch" }}
style={{
background: "{color.surface}",
radius: "{radius.lg}",
borderWidth: "{border.hairline}",
borderColor: "{color.line}",
}}
>
<Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>Quests</Text>
<List
items="quests.open"
as="q"
keyPath="id"
layout={{ gap: "{space.2}", align: "stretch" }}
// A slot, not a condition: the IR has no expressions to evaluate, so
// "nothing here yet" is authored as a node that is in layout only
// while the bound array is empty.
empty={
<Text style={{ color: "{color.faint}", fontSize: "{text.sm}" }}>
No quests in your log
</Text>
}
>
{(q) => (
<Row
layout={{ height: 52, padding: "{space.2}", gap: "{space.3}", align: "center" }}
style={{ background: "{color.slot}", radius: "{radius.md}" }}
>
{/* The element's position — the data has no such field. */}
<Text
bind={q("$index")}
layout={{ width: 18 }}
style={{ color: "{color.faint}", fontSize: "{text.xs}" }}
/>
<Column layout={{ grow: 1, gap: 2 }}>
<Text bind={q("name")} style={{ color: "{color.text}", fontSize: "{text.sm}" }} />
<Text bind={q("area")} style={{ color: "{color.faint}", fontSize: "{text.xs}" }} />
</Column>
{/* One action for the whole list. Which row it came from travels
in the action context, not in a different action name. */}
<Button
variant="secondary"
onClick="track"
layout={{ width: 78, height: 32, justify: "center", align: "center" }}
>
<Text style={{ color: "{color.text}", fontSize: "{text.xs}" }}>Track</Text>
</Button>
</Row>
)}
</List>
</Column>
</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": {
"repeat-list": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "stretch",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"id": "panel",
"layout": {
"direction": "column",
"padding": "{space.5}",
"gap": "{space.4}",
"align": "stretch"
},
"style": {
"background": "{color.surface}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.lg}"
},
"text": "Quests"
},
{
"type": "Repeat",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "stretch"
},
"items": {
"bind": "quests.open"
},
"as": "q",
"key": "id",
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"height": 52,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"width": 18
},
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": {
"bind": "q.$index"
}
},
{
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"gap": 2
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": {
"bind": "q.name"
}
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": {
"bind": "q.area"
}
}
]
},
{
"type": "Button",
"layout": {
"width": 78,
"height": 32,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "track",
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.xs}"
},
"text": "Track"
}
]
}
]
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.sm}"
},
"text": "No quests in your log"
}
]
}
]
}
]
}
}
}It is a node type rather than a prop on Container for the reason that has
settled every similar question in this format: the SDK dispatches behavior by
type, never by type and prop.
The two tables below are where the authoring conveniences land as structure.
empty is a prop you write and a slot that ships; keyPath is called key
once React is out of the picture and there is no key of its own to collide
with.
Authoring props
There is no <Repeat> component: the slots are positional, and <List> and
<Grid> are the one place that convention is written down. They share these
props.
| Prop | Type | Default | Description |
|---|---|---|---|
| items | string | — | Data path of the array, e.g. "shop.items". |
| as | string | "item" | Alias the template binds against. |
| keyPath | string | absent | Path relative to the item naming its identity. Named keyPath because React owns key. |
| empty | ReactNode | absent | Shown while the array is empty, absent or not an array. |
| children | node | (item) => node | absent | The item template — a single node. |
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. <List> adds axis; <Grid> adds columns, itemWidth
and cell.
IR props
What ships to the game, after the authoring layer is gone.
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { bind: string } | — | The bound array. Always a binding. |
| as | string | "item" | Alias the template binds against. |
| key | string | absent | Path relative to the item naming its stable identity. Absent = positional. |
| children | ZNode[] | absent | children[0] = item template; children[1..] = empty state. |
A literal array here would put game data into the document, and the document carries structure. That line is what lets the same envelope be published once and filled in by every player’s own state.
The Repeat is the container
Its own layout — direction, gap, padding, justify, align, wrap —
lays the instances out, exactly as any container lays out its children. That is
what lets <List> and <Grid> be authoring sugar over it rather than node types
of their own.
Slots
children[0]is the template, emitted once and instantiated per element.children[1..]is the empty state, in layout only while the bound value is an empty array, absent, or not an array at all —display:nonesemantics, the one hiding mechanism.
The empty state exists as a slot because “show Nothing here yet” would otherwise need a boolean expression over the data, and the IR has no expressions by design.
Binding inside the template
Paths in the template may start with the alias and are resolved against the current element:
| Path | Resolves to |
|---|---|
"item" | the element itself — "shop.items.3" |
"item.name" | "shop.items.3.name" |
"item.$index" | the element’s position — a number the data does not contain |
"player.gold" | itself: a path under no known alias is absolute |
Scopes nest and the innermost alias wins, which is why the alias is declared rather than reserved: a nested list can still reach the outer element by its own name. It also means an alias shadows a data root of the same name — pick names that are not roots of your data.
Identity
key names a path relative to the item pointing at a stable field ("id",
"meta.sku"). Identity is what makes updates stable: reordering a keyed array
moves the SDK’s per-item state — focus, a checked Toggle, a scroll offset, an
in-flight transition — with the item instead of leaving it pinned to a
position. It is also what makes recycling and virtualization possible, and the
focus is keyed by that identity too, so it survives a row being un-realized.
Without a key, identity is positional.
Behavior
- States
disabledonly, and it reaches every instance — one prop switches off a whole list of rows at once, however many the data turns out to hold.- Focusable
No, and nothing the player does reaches the
Repeatitself. The instances keep their own focusability; the node that produced them takes no input.- Layout
Its own, applied to the instances. A
<Grid>is this node withwrapand a cell width — the geometry is arithmetic, solved at authoring time, because v1 has no fractional dims.- Actions
None of its own — but the player presses a button inside a row → that button’s
onClickfires → and the game hears the name plus an action context naming the row it came from. That context is the whole capability this node adds to an action.
Degradation
On an older SDK the row shows once, blank, with the 'nothing here yet' message sitting underneath it.
As a Container: the template shows ONCE, static and unresolved — its bindings read nothing — alongside the empty state. The content survives; the repetition does not. A list of ten rows becomes one blank row, which is legible as a fallback rather than as a crash.
How the game hears this
One name for every row; which row it was rides along with it. A single
onClick: "track" in the template becomes one button per quest, and the action
arrives with a context — { path, key, index } — saying where it fired from.
That is what lets a list of ten quests need one action name rather than ten, and
what keeps the UI from knowing anything about quests.
The array itself moves through SetData: writing quests.open re-instantiates
the template, and writing into one item moves the bindings inside that row.
using UnityEngine;
using Zabloo;
[RequireComponent(typeof(ZablooDocument))]
public sealed class QuestLog : MonoBehaviour
{
ZablooDocument _doc;
void Start()
{
_doc = GetComponent<ZablooDocument>();
_doc.OnAction += OnZablooAction;
// The rows are the game's own array. The document carries the template,
// never the data — which is why this line is the whole "populate" step.
_doc.SetData("quests.open", QuestBook.Open);
}
void OnDestroy()
{
if (_doc != null) _doc.OnAction -= OnZablooAction;
}
void OnZablooAction(string action)
{
// One action for every row. Which one it came from rides in the context.
if (action == "track") TrackSelectedQuest();
}
}When the action fires from inside a Repeat item it carries an action context — the item's path, key and index — so the game can tell which row was pressed. The context is part of the format and the browser renderer delivers it; Unity's OnAction is Action<string> today and hands over the name alone, so a Unity game that needs the row reads it from its own state until that lands.
Composition
Repetition is one capability and scrolling is another, so a long list is a
<List> inside a <ScrollView>.
<List>— use it for rows stacked in one direction: a quest log, an inbox, a shop’s stock.<Grid>— use it when the cells are of a known width and should wrap: an inventory, a level select, a character roster.empty— always fill it in, because “the array is empty” is a state the player will reach and there is no expression that could cover for you.
// List: the vertical case. The render-prop form gives you the item's paths
// as a function — it("name") → "it.name" — so renaming `as` follows through.
<List items="shop.items" as="it" keyPath="id" layout={{ gap: 8 }}
empty={<Text>Nothing here yet</Text>}>
{(it) => (
<Row layout={{ gap: 12, align: "center" }}>
<Text bind={it("name")} />
<Text bind={it("price")} />
<Button onClick="buy"><Text>Buy</Text></Button>
</Row>
)}
</List>
// Grid: the same Repeat, laid out as a wrapping row of sized cells.
// `columns` never reaches the IR — it is arithmetic done at authoring time,
// which is also why gap and padding must be numbers here, not tokens.
<Grid items="inventory.slots" columns={4} itemWidth={72} layout={{ gap: 8 }}>
{(slot) => <Text bind={slot("name")} />}
</Grid>The template is a single node because children[0] is the template: wrap
an item’s contents in a <Row> or a <Column>.