Skip to content
zabloo

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.

spinner-loop.viewIR v1
Two activity indicators side by side: three purple dots under the label Loading world, and five purple bars of rising and falling height under the label Syncing. Each bead is painted at its own brightness — the wave that travels through them.
STATE
  • 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>
  );
}
Two indicators, one node type: three generated dots and five beads of your own. Press Run and follow one bead rather than the group — it never moves, and nothing turns. All that changes is opacity, which is the only quantity every target computes to exactly the same number.

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.

PropTypeDefaultDescription
dotsnumber3How many beads to build when you pass no children of your own.
sizenumber8Bead diameter in px (generated beads only).
periodDim900Full cycle in ms. A Dim, so the loop is themeable.
minnumber0.25Opacity multiplier at the wave's dimmest, 0..1.
easingEasing"ease-in-out"Curve of the ramp up and back down.
dotStyleabsentStyle of each generated bead, merged over the default.
childrenReactNodeabsentYour 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.

PropTypeDefaultDescription
periodDim900Full cycle in ms. <= 0 or non-finite freezes it.
minnumber0.25Trough of the wave: the opacity multiplier at its dimmest.
easingEasing"ease-in-out"Curve of the ramp.
childrenZNode[][]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.

A reduce-motion theme freezes it by moving one token

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

disabled only, inherited — and it keeps running while disabled. disabled is 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 period of <= 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 visible when the work is done.

Degradation

On an older SDK

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, size and min to 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 ProgressBar instead — 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" }} />