Spinner
The indicator for work with no measurable end: it says something is happening, without claiming how far along. Its children pulse in a travelling wave — nothing rotates, because v1 has no transform.
primitivesince v1not focusable
A Spinner says something is happening, and I cannot tell you how far along it
is. You reach for one when there is no fraction to show — connecting to a
server, finding a match, loading a level — and you reach for a
ProgressBar the moment there is. Picture a
matchmaking panel: three dots under the word Searching, brightening in turn.
They brighten rather than rotate, and the next section is about why that is a
decision rather than a shortcut.

- 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, Container, Row, Spinner, Text } from "@zabloo/react";
const BAR = {
background: "{color.brand}",
radius: "{radius.sm}",
} as const;
export default function SpinnerLoop() {
return (
<Column
layout={{ grow: 1, justify: "center", align: "center", padding: "{space.6}" }}
style={{ background: "{color.bg}" }}
>
<Row layout={{ gap: "{space.4}", align: "stretch" }}>
<Column
layout={{
width: 200,
height: 120,
padding: "{space.4}",
gap: "{space.3}",
justify: "center",
align: "center",
}}
style={{
background: "{color.surface}",
radius: "{radius.lg}",
borderWidth: "{border.hairline}",
borderColor: "{color.line}",
}}
>
{/* No children: the component builds `dots` round beads for you. */}
<Spinner
id="dots"
dots={3}
size={10}
period="{motion.loop}"
dot={{ background: "{color.brand}", radius: "{radius.pill}" }}
layout={{ gap: "{space.2}", align: "center" }}
/>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Loading world</Text>
</Column>
<Column
layout={{
width: 200,
height: 120,
padding: "{space.4}",
gap: "{space.3}",
justify: "center",
align: "center",
}}
style={{
background: "{color.surface}",
radius: "{radius.lg}",
borderWidth: "{border.hairline}",
borderColor: "{color.line}",
}}
>
{/* Your own beads, in wave order. They are ordinary children in every
respect but the opacity the loop multiplies into them. */}
<Spinner id="bars" period="{motion.loop}" min={0.15} layout={{ gap: 5, align: "center" }}>
<Container layout={{ width: 5, height: 14 }} style={BAR} />
<Container layout={{ width: 5, height: 22 }} style={BAR} />
<Container layout={{ width: 5, height: 30 }} style={BAR} />
<Container layout={{ width: 5, height: 22 }} style={BAR} />
<Container layout={{ width: 5, height: 14 }} style={BAR} />
</Spinner>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Syncing</Text>
</Column>
</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": {
"spinner-loop": {
"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.4}",
"align": "stretch"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "column",
"width": 200,
"height": 120,
"padding": "{space.4}",
"gap": "{space.3}",
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.surface}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Spinner",
"id": "dots",
"layout": {
"direction": "row",
"align": "center",
"gap": "{space.2}"
},
"period": "{motion.loop}",
"children": [
{
"type": "Container",
"layout": {
"width": 10,
"height": 10
},
"style": {
"radius": "{radius.pill}",
"background": "{color.brand}"
}
},
{
"type": "Container",
"layout": {
"width": 10,
"height": 10
},
"style": {
"radius": "{radius.pill}",
"background": "{color.brand}"
}
},
{
"type": "Container",
"layout": {
"width": 10,
"height": 10
},
"style": {
"radius": "{radius.pill}",
"background": "{color.brand}"
}
}
]
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Loading world"
}
]
},
{
"type": "Container",
"layout": {
"direction": "column",
"width": 200,
"height": 120,
"padding": "{space.4}",
"gap": "{space.3}",
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.surface}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Spinner",
"id": "bars",
"layout": {
"direction": "row",
"align": "center",
"gap": 5
},
"period": "{motion.loop}",
"min": 0.15,
"children": [
{
"type": "Container",
"layout": {
"width": 5,
"height": 14
},
"style": {
"background": "{color.brand}",
"radius": "{radius.sm}"
}
},
{
"type": "Container",
"layout": {
"width": 5,
"height": 22
},
"style": {
"background": "{color.brand}",
"radius": "{radius.sm}"
}
},
{
"type": "Container",
"layout": {
"width": 5,
"height": 30
},
"style": {
"background": "{color.brand}",
"radius": "{radius.sm}"
}
},
{
"type": "Container",
"layout": {
"width": 5,
"height": 22
},
"style": {
"background": "{color.brand}",
"radius": "{radius.sm}"
}
},
{
"type": "Container",
"layout": {
"width": 5,
"height": 14
},
"style": {
"background": "{color.brand}",
"radius": "{radius.sm}"
}
}
]
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Syncing"
}
]
}
]
}
]
}
}
}It does not spin
v1 has no transform — no translate, rotate or scale — so a rotating arc is
not expressible. What is expressible, and portable to the last decimal, is a
periodic modulation of opacity.
That leaves the question of why it is a node type at all, and the answer is the loop: an infinite animation is behavior owned by the SDK and keyed by component identity, and that identity has to exist in the IR. Nothing else in the format repeats forever.
The two tables below are the widest gap in the catalog after Toggle’s. dots,
size and dot are instructions for building beads, and they are gone by the
time anything ships: what the game receives is the beads themselves, as ordinary
child nodes.
Authoring props
What you write in @zabloo/react.
| Prop | Type | Default | Description |
|---|---|---|---|
| dots | number | 3 | How many beads to build when you pass no children of your own. |
| size | number | 8 | Bead diameter in px (generated beads only). |
| period | Dim | 900 | Full cycle in ms. A Dim, so the loop is themeable. |
| min | number | 0.25 | Opacity multiplier at the wave's dimmest, 0..1. |
| easing | Easing | "ease-in-out" | Curve of the ramp up and back down. |
| dot | Style | absent | Style of each generated bead, merged over the default. |
| children | ReactNode | absent | Your own beads, in wave order — replaces the generated dots entirely. |
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 |
|---|---|---|---|
| period | Dim | 900 | Full cycle in ms. <= 0 or non-finite freezes it. |
| min | number | 0.25 | Trough of the wave: the opacity multiplier at its dimmest. |
| easing | Easing | "ease-in-out" | Curve of the ramp. |
| children | ZNode[] | [] | The beads, in wave order — ordinary children in every other respect. |
That is why passing your own children replaces the generated dots rather than configuring them: by the time anything ships, there was never a difference between the two.
The wave
With n children, child i carries the phase frac(elapsed / period − i / n),
and the SDK multiplies its resolved opacity by
min + (1 − min) · spinnerPulse(phase, easing).
Multiplicative, like every other opacity in the system — so a bead authored at
opacity: 0.5 still pulses, just dimmer. spinnerPulse is a symmetric ramp: up
over the first half of the cycle and back down over the second, so the loop is
seamless, and phases outside 0..1 wrap, including the negative ones a bead’s
offset produces. It is built on the same closed-form easing the rest of the
motion engine uses, for the same reason that exists: arithmetic is what keeps
every target on the same number.
The beads keep their normal layout — the node’s own direction, gap and
align place them like any container’s children.
period is a Dim, so it can be "{motion.loop}". A theme that zeroes the motion
tokens stops this spinner without the renderer needing a switch — which is
exactly how the stage above honours your system’s reduced-motion setting: it swaps the
dictionary before mounting, and the loop is one of the values in it.
Behavior
- States
disabledonly, inherited — and it keeps running while disabled.disabledis about input, and a spinner takes none either way, so switching off the panel around it does not freeze the news that something is still loading.- Focusable
No, and there is nothing for the player to do to it. It reports that something is happening; there is nothing to activate.
- The loop
Owned by the SDK and keyed by this node’s identity, which is why it survives a relayout and why two spinners in a list do not share a phase. A
periodof<= 0, or a non-finite one, freezes the wave rather than dividing by zero.- Actions
None, in either direction: the game hears nothing from a spinner, and does not have to feed it either. Hide it with
visiblewhen the work is done.
Degradation
On an older SDK the same dots show in the same places — they simply never move.
As a Container: the beads show, at rest. The degradation is the absence of the loop, never a layout change — an older SDK draws exactly the same three dots, and they do not breathe.
Composition
The beads are ordinary nodes, so an indicator’s shape is a layout question: round dots, bars of different heights, a row of icons. What the node adds is the wave that travels through them.
- The bare
<Spinner>— use it when you want the house indicator and have no reason to think about it. - Tuned generated beads — use
dots,sizeandminto fit an indicator to a tight corner of a HUD. - Your own beads — pass children when the shape carries meaning: equaliser bars, a row of suit icons, anything that is not a dot.
- A
ProgressBarinstead — the moment the game knows how far along it is, this node is the wrong one.
// Generated beads: three round dots, the default.
<Spinner />
// More of them, dimmer trough, and a themeable loop.
<Spinner dots={5} size={6} period="{motion.loop}" min={0.15} />
// Your own beads, in wave order. They replace the generated dots entirely.
<Spinner>
<Container layout={{ width: 4, height: 16 }} style={{ background: "{color.dot}" }} />
<Container layout={{ width: 4, height: 22 }} style={{ background: "{color.dot}" }} />
</Spinner>
// A determinate amount is not a Spinner. A fraction is a ProgressBar.
<ProgressBar value={{ bind: "download.progress" }} />