Spinner
El indicador para un trabajo sin final medible: dice que algo está pasando, sin afirmar cuánto lleva. Sus hijos laten en una onda que viaja — no gira nada, porque la v1 no tiene transform.
primitivedesde v1sin focus
Un Spinner dice algo está pasando, y no te puedo decir cuánto lleva. Usas uno
cuando no hay ninguna fracción que enseñar —conectar con un servidor, buscar
partida, cargar un nivel— y usas una
ProgressBar en cuanto la haya. Piensa en
un panel de emparejamiento: tres puntos debajo de la palabra Searching,
iluminándose por turnos. Se iluminan en vez de girar, y la sección siguiente va
de por qué eso es una decisión y no un atajo.

- hover — inactivo
- pressed — inactivo
- focused — inactivo
- selected — inactivo
- disabled — inactivo
Se lee del último frame dibujado
Parado: pulsa Ejecutar para dibujarlo en la 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"
}
]
}
]
}
]
}
}
}No gira
La v1 no tiene transform —ni translate, ni rotate, ni scale—, así que un arco
girando no se puede expresar. Lo que sí se puede expresar, y es portable hasta
el último decimal, es una modulación periódica de la opacity.
Queda la pregunta de por qué es un tipo de nodo siquiera, y la respuesta es el bucle: una animación infinita es comportamiento del que es dueño el SDK e indexado por la identidad del componente, y esa identidad tiene que existir en la IR. No hay nada más en el formato que se repita para siempre.
Las dos tablas de abajo son la distancia más grande del catálogo después de la de
Toggle. dots, size y dot son instrucciones para construir cuentas, y ya
no existen cuando se publica nada: lo que recibe el juego son las cuentas mismas,
como nodos hijos normales.
Props de autoría
Lo que escribes en @zabloo/react.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| dots | number | 3 | Cuántas cuentas construir cuando no pasas hijos propios. |
| size | number | 8 | Diámetro de la cuenta en px (solo para las generadas). |
| period | Dim | 900 | Ciclo completo en ms. Es un Dim, así que el bucle es tematizable. |
| min | number | 0.25 | Multiplicador de opacidad en lo más apagado de la onda, 0..1. |
| easing | Easing | "ease-in-out" | Curva de la subida y la vuelta abajo. |
| dot | Style | absent | Estilo de cada cuenta generada, mezclado sobre el de por defecto. |
| children | ReactNode | absent | Tus propias cuentas, en orden de onda — sustituyen del todo a los puntos generados. |
Además de estas, todo componente acepta las props base del nodo —id,
visible, disabled, layout, style, states, transition, autofocus,
clip— más variant, que el theme resuelve al exportar y que nunca aparece en
la IR.
Props de IR
Lo que llega al juego, cuando ya no queda capa de autoría.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| period | Dim | 900 | Ciclo completo en ms. <= 0 o no finito lo congela. |
| min | number | 0.25 | El valle de la onda: el multiplicador de opacidad en lo más apagado. |
| easing | Easing | "ease-in-out" | Curva de la rampa. |
| children | ZNode[] | [] | Las cuentas, en orden de onda — hijos normales en todo lo demás. |
Por eso pasar hijos propios sustituye a los puntos generados en vez de configurarlos: cuando se publica algo, entre los dos nunca hubo diferencia.
La onda
Con n hijos, el hijo i lleva la fase frac(elapsed / period − i / n), y el
SDK multiplica su opacidad ya resuelta por
min + (1 − min) · spinnerPulse(fase, easing).
Multiplicativo, como cualquier otra opacidad del sistema — así que una cuenta
escrita con opacity: 0.5 sigue latiendo, solo que más apagada. spinnerPulse
es una rampa simétrica: sube durante la primera mitad del ciclo y vuelve a bajar
durante la segunda, así que el bucle no tiene costura, y las fases fuera de
0..1 dan la vuelta, incluidas las negativas que produce el desfase de una
cuenta. Está construida sobre el mismo easing en forma cerrada que usa el resto
del motor de movimiento, por la razón por la que ese existe: la aritmética es lo
que mantiene a todos los targets en el mismo número.
Las cuentas conservan su layout normal: la direction, el gap y el align
del propio nodo las colocan como a los hijos de cualquier contenedor.
period es un Dim, así que puede ser "{motion.loop}". Un theme que ponga a cero los
tokens de movimiento detiene este spinner sin que el renderer necesite
ningún interruptor — que es exactamente como la figura de arriba respeta el ajuste de movimiento
reducido de tu sistema: cambia el diccionario antes de montar, y el bucle es uno de sus valores.
Comportamiento
- Estados
Solo
disabled, heredado — y sigue corriendo mientras está deshabilitado.disabledva de input, y un spinner no acepta ninguno de todas formas, así que apagar el panel de alrededor no congela la noticia de que algo se sigue cargando.- Focusable
No, y no hay nada que el jugador pueda hacerle. Informa de que algo está pasando; no hay nada que activar.
- El bucle
Es del SDK e indexado por la identidad de este nodo, que es la razón de que sobreviva a un relayout y de que dos spinners de una lista no compartan fase. Un
periodde<= 0, o uno no finito, congela la onda en vez de dividir por cero.- Actions
Ninguna, en ninguno de los dos sentidos: el juego no oye nada de un spinner, y tampoco tiene que alimentarlo. Escóndelo con
visiblecuando el trabajo termine.
Degradación
En un SDK más antiguo se ven los mismos puntos en los mismos sitios — simplemente no se mueven nunca.
Como Container: las cuentas se ven, quietas. La degradación es la ausencia del bucle, nunca un cambio de layout — un SDK más antiguo dibuja exactamente los mismos tres puntos, y no respiran.
Composición
Las cuentas son nodos normales, así que la forma de un indicador es una cuestión de layout: puntos redondos, barras de distintas alturas, una fila de iconos. Lo que añade el nodo es la onda que las recorre.
- El
<Spinner>pelado — cuando quieras el indicador de la casa y no tengas ninguna razón para pensar en él. - Cuentas generadas y ajustadas — usa
dots,sizeyminpara encajar un indicador en una esquina justa de un HUD. - Tus propias cuentas — pasa hijos cuando la forma signifique algo: barras de ecualizador, una fila de iconos de palo, cualquier cosa que no sea un punto.
- Una
ProgressBaren su lugar — en cuanto el juego sepa cuánto lleva, este nodo es el equivocado.
// 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" }} />