Toggle
Un control que está encendido o apagado. La checkbox, el switch y el radio son el mismo nodo — lo que cambia es cómo va vestido y si está dentro de un grupo.
primitivedesde v1focusable
Un Toggle tiene uno de dos estados: encendido o apagado. Lo usas siempre que
un ajuste es un sí o un no, y siempre que el jugador elige exactamente una
opción de una lista corta. Piensa en una pantalla de ajustes: la checkbox
Sound effects es un Toggle por su cuenta, con un booleano que el juego lee;
la fila Low / Medium / High de debajo son otros tres compartiendo un único
valor, que es la razón de que elegir High apague Low sin que ninguno de
los dos sepa que el otro existe.

- 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 { Checkbox, Column, Radio, RadioGroup, Switch, Text } from "@zabloo/react";
export default function ToggleControls() {
return (
<Column
layout={{ grow: 1, justify: "center", align: "center", padding: "{space.6}" }}
style={{ background: "{color.bg}" }}
>
<Column
id="panel"
layout={{ width: 400, 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}" }}>Audio & video</Text>
{/* A read/write binding: tapping writes the new boolean into the game's
data and notifies it. `onChange` is the other leg — the named action. */}
<Checkbox
id="sfx"
checked={{ bind: "settings.sfx" }}
onChange="sfx-changed"
box={{
background: "{color.slot}",
radius: "{radius.sm}",
borderWidth: "{border.hairline}",
borderColor: "{color.line-strong}",
}}
checkedBox={{ background: "{color.brand-strong}", borderColor: "{color.brand}" }}
mark={{ background: "{color.on-brand}" }}
>
<Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Sound effects</Text>
</Checkbox>
{/* The same primitive. The knob "moves" because each slot justifies it
to a different end of the track — layout, not animation. */}
<Switch
id="fullscreen"
checked={{ bind: "settings.fullscreen" }}
track={{ background: "{color.slot}", radius: "{radius.pill}" }}
checkedTrack={{ background: "{color.brand-strong}" }}
knob={{ background: "{color.text}", radius: "{radius.pill}" }}
>
<Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Fullscreen</Text>
</Switch>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Quality</Text>
{/* One value for the whole group: each Radio is checked while its own
value equals it, so the losing option never has to be told. */}
<RadioGroup
value={{ bind: "settings.quality" }}
onChange="quality-changed"
layout={{ gap: "{space.2}" }}
>
<Radio
value="low"
box={{
radius: "{radius.pill}",
borderWidth: "{border.hairline}",
borderColor: "{color.line-strong}",
}}
checkedBox={{ borderColor: "{color.brand}" }}
mark={{ background: "{color.brand}", radius: "{radius.pill}" }}
>
<Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Low</Text>
</Radio>
<Radio
value="high"
box={{
radius: "{radius.pill}",
borderWidth: "{border.hairline}",
borderColor: "{color.line-strong}",
}}
checkedBox={{ borderColor: "{color.brand}" }}
mark={{ background: "{color.brand}", radius: "{radius.pill}" }}
>
<Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>High</Text>
</Radio>
</RadioGroup>
</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": {
"toggle-controls": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "center",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"id": "panel",
"layout": {
"direction": "column",
"width": 400,
"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": "Audio & video"
},
{
"type": "Toggle",
"id": "sfx",
"layout": {
"direction": "row",
"align": "center",
"gap": 10
},
"checked": {
"bind": "settings.sfx"
},
"onChange": "sfx-changed",
"children": [
{
"type": "Container",
"layout": {
"width": 22,
"height": 22,
"justify": "center",
"align": "center"
},
"style": {
"borderWidth": "{border.hairline}",
"borderColor": "{color.brand}",
"radius": "{radius.sm}",
"background": "{color.brand-strong}"
},
"children": [
{
"type": "Container",
"layout": {
"width": 10,
"height": 10
},
"style": {
"background": "{color.on-brand}",
"radius": 2
}
}
]
},
{
"type": "Container",
"layout": {
"width": 22,
"height": 22,
"justify": "center",
"align": "center"
},
"style": {
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.sm}",
"background": "{color.slot}"
}
},
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Sound effects"
}
]
},
{
"type": "Toggle",
"id": "fullscreen",
"layout": {
"direction": "row",
"align": "center",
"gap": 10
},
"checked": {
"bind": "settings.fullscreen"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"width": 40,
"height": 22,
"padding": 3,
"justify": "end",
"align": "center"
},
"style": {
"radius": "{radius.pill}",
"background": "{color.brand-strong}"
},
"children": [
{
"type": "Container",
"layout": {
"width": 16,
"height": 16
},
"style": {
"radius": "{radius.pill}",
"background": "{color.text}"
}
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"width": 40,
"height": 22,
"padding": 3,
"justify": "start",
"align": "center"
},
"style": {
"radius": "{radius.pill}",
"background": "{color.slot}"
},
"children": [
{
"type": "Container",
"layout": {
"width": 16,
"height": 16
},
"style": {
"radius": "{radius.pill}",
"background": "{color.text}"
}
}
]
},
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Fullscreen"
}
]
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Quality"
},
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}"
},
"group": "exclusive-check",
"value": {
"bind": "settings.quality"
},
"onChange": "quality-changed",
"children": [
{
"type": "Toggle",
"layout": {
"direction": "row",
"align": "center",
"gap": 10
},
"value": "low",
"children": [
{
"type": "Container",
"layout": {
"width": 22,
"height": 22,
"justify": "center",
"align": "center"
},
"style": {
"borderWidth": "{border.hairline}",
"borderColor": "{color.brand}",
"radius": "{radius.pill}",
"background": "#4f46e5"
},
"children": [
{
"type": "Container",
"layout": {
"width": 10,
"height": 10
},
"style": {
"background": "{color.brand}",
"radius": "{radius.pill}"
}
}
]
},
{
"type": "Container",
"layout": {
"width": 22,
"height": 22,
"justify": "center",
"align": "center"
},
"style": {
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.pill}"
}
},
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.sm}"
},
"text": "Low"
}
]
},
{
"type": "Toggle",
"layout": {
"direction": "row",
"align": "center",
"gap": 10
},
"value": "high",
"children": [
{
"type": "Container",
"layout": {
"width": 22,
"height": 22,
"justify": "center",
"align": "center"
},
"style": {
"borderWidth": "{border.hairline}",
"borderColor": "{color.brand}",
"radius": "{radius.pill}",
"background": "#4f46e5"
},
"children": [
{
"type": "Container",
"layout": {
"width": 10,
"height": 10
},
"style": {
"background": "{color.brand}",
"radius": "{radius.pill}"
}
}
]
},
{
"type": "Container",
"layout": {
"width": 22,
"height": 22,
"justify": "center",
"align": "center"
},
"style": {
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.pill}"
}
},
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.sm}"
},
"text": "High"
}
]
}
]
}
]
}
]
}
}
}Las dos tablas de abajo están más lejos entre sí que en ninguna otra página, y
esa distancia es la razón de ser de la capa de componentes: box, checkedBox,
mark, track y knob no son props de este nodo. Son los estilos de los
dos hijos que el componente construye por ti, y lo que se publica son esos
hijos, nunca las palabras.
Props de autoría
No existe un componente <Toggle>. Los slots de abajo son posicionales, y
los cuatro controles que emiten este nodo son el único sitio donde esa
convención está escrita — por eso nunca los rellenas a mano. Todos comparten
estas props.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| checked | Bindable<boolean> | false | Estado inicial, o un binding de lectura y escritura. |
| onChange | string | absent | Named action que se dispara en cada cambio. |
| size | number | 22 | Tamaño del indicador en px — el lado de la caja, o el alto del track del switch. |
| children | ReactNode | absent | La etiqueta. Tocarla también cambia el estado. |
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. <Checkbox> añade box, checkedBox y mark; <Switch> añade
track, checkedTrack y knob; <Radio> exige un value.
Props de IR
Lo que llega al juego, cuando ya no queda capa de autoría.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| checked | Bindable<boolean> | false | Estado inicial, o un binding de lectura y escritura. |
| value | string | number | absent | El valor de esta opción dentro de un grupo "exclusive-check". |
| onChange | string | absent | Named action que se dispara en cada cambio. |
| children | ZNode[] | [] | Slots posicionales: indicador marcado, indicador sin marcar, etiqueta. |
Los slots del indicador
Pintar es implícito —no hay una capa de comandos de dibujo—, así que la marca o el knob se componen, no los dibuja un primitive nuevo:
| Slot | Se ve |
|---|---|
children[0] | solo mientras está checked |
children[1] | solo mientras está sin checked |
children[2..] | siempre — es la etiqueta |
Los slots entran y salen del layout con la semántica de display:none, el mismo
y único mecanismo de ocultar que un Collapse usa para su contenido. Un switch
mueve su knob intercambiando dos slots con distinto justify; una checkbox
enseña su marca. Cada slot pinta el indicador entero tal y como se ve en ese
estado, que es lo que mantiene en pie la regla de que nada estiliza a un
descendiente por estado.
Los dos slots del indicador comparten una caja en el flujo: se miden como un solo item, se quedan con el mayor de los dos, y ambos reciben el mismo rect. Eso es lo que les permite hacer un crossfade sin que la etiqueta se mueva.
Comportamiento
- Estados
checked, máshover,pressedyfocused— ydisabled, propio o heredado. Un toggle deshabilitado conserva su estadochecked: lo que tiene y si el jugador puede cambiarlo son dos afirmaciones distintas, y por esodisabledse mezcla el último.- Focusable
Sí, salvo que esté
disabled. El jugador lo toca, pulsa Enter mientras lo tiene con el focus, o pulsa A en el mando — y ninguna de las tres cosas llega mientras está deshabilitado.- Valor
Por su cuenta lleva un booleano:
checkedes o un valor inicial literal o un binding de lectura y escritura que el SDK escribe en su almacén de datos. Dentro de un grupo"exclusive-check"es derivado delvaluedel grupo y no se guarda nunca por nodo: tocarlo escribe su propiovalueen el del grupo.- Actions
El jugador lo toca → el SDK da la vuelta al valor y lo escribe en los datos → se dispara
onChangecon la named action que le hayas puesto. Se dispara en cada cambio venga de donde venga, incluido elSetCheckeddel propio juego. Dentro de un grupo solo se dispara cuando esta opción se queda con la selección: un radio nunca se apaga a sí mismo, así que el que la pierde no dice nada.
El SDK interpola un progreso de 0..1 para el checked y multiplica por él la opacidad de cada
slot: children[0] aparece mientras children[1] se va. Para eso sirve la caja compartida: la
etiqueta no se mueve mientras aparece la marca o se cambia el knob. Es comportamiento del
componente empujando al motor de movimiento con los extremos que él mismo calcula, no una prop
animable, y se compone con la opacity que el slot ya declare.
Degradación
En un SDK más antiguo se ven las dos mitades del control a la vez —la marca y la caja vacía, una al lado de la otra— y tocar cualquiera de ellas no hace nada.
Como Container, y este merece la pena imaginárselo: LOS DOS slots del indicador están en el layout, más la etiqueta, porque no hay nada escondiendo uno de ellos. El control está inerte. Todo lo que escribió su autor está en pantalla; lo que falta es la regla de que solo uno de los dos pinta ahí.
Cómo oye esto el juego
El binding lleva el valor; la action lleva el momento. Un toggle habla por las dos patas a la vez, y cada una responde a una pregunta distinta. El binding es el booleano: el SDK escribe el nuevo en el almacén de datos, y todo lo que esté leyendo ese path vuelve a maquetarse. La action es el evento —esto ha cambiado, ahora— y, como toda action en la v1, no lleva valor propio. Una pantalla de ajustes que guarda al salir solo necesita el binding; una que aplica al instante depende de la action.
En el otro sentido está SetChecked(id, checked), y no es un empujón al estado:
recorre el mismo camino que el dedo del jugador, así que dispara onChange —y
el del grupo, si está dentro de uno— exactamente igual que un toque.
using UnityEngine;
using Zabloo;
[RequireComponent(typeof(ZablooDocument))]
public sealed class Settings : MonoBehaviour
{
[SerializeField] bool _sfx = true;
ZablooDocument _doc;
// Start, not OnEnable: it runs after ZablooDocument has built the view.
void Start()
{
_doc = GetComponent<ZablooDocument>();
_doc.OnAction += OnZablooAction;
// The game owns the flag; the checkbox reads it through the binding.
// SetData is cached, so pushing it before the node exists is fine.
_doc.SetData("settings.sfx", _sfx);
}
void OnDestroy()
{
if (_doc != null) _doc.OnAction -= OnZablooAction;
}
void OnZablooAction(string action)
{
if (action != "sfx-changed") return;
// A standalone checkbox reports every change, and every change is a flip.
_sfx = !_sfx;
ApplySfx(_sfx);
}
}Cuando la action se dispara desde dentro de un item de Repeat lleva un action context —el path, la key y el índice del item— para que el juego sepa qué fila se ha pulsado. El context es parte del formato y el renderer del navegador lo entrega; el OnAction de Unity es hoy Action<string> y entrega solo el nombre, así que un juego de Unity que necesite la fila la lee de su propio estado hasta que eso aterrice.
Composición
Cuatro nombres, un nodo. Lo que cambia de uno a otro es qué slots construye el componente y si está dentro de un grupo.
<Checkbox>— para un sí o no independiente dentro de una lista de ajustes.<Switch>— lo mismo, vestido de track y knob; úsalo cuando el ajuste surta efecto al momento.<RadioGroup>— cuando el jugador tenga que elegir una de unas pocas opciones que caben todas en pantalla a la vez.<Select>— para esa misma elección única cuando la lista sea demasiado larga para enseñarla, o el hueco demasiado justo.
// Checkbox and Switch: an independent boolean each.
<Checkbox checked={{ bind: "settings.sfx" }} onChange="sfx-changed">
<Text>Sound effects</Text>
</Checkbox>
<Switch checked={{ bind: "settings.fullscreen" }}>
<Text>Fullscreen</Text>
</Switch>
// Radio and RadioGroup: the group owns the selection, so a Radio has a value
// instead of a checked. Put onChange on the GROUP — it is the node that can
// say what was picked.
<RadioGroup value={{ bind: "settings.quality" }} onChange="quality-changed" layout={{ gap: 8 }}>
<Radio value="low"><Text>Low</Text></Radio>
<Radio value="high"><Text>High</Text></Radio>
</RadioGroup>
// Select: a Button, an Overlay anchored to it with trigger: "press", and a
// ScrollView around the same "exclusive-check" group. A composite, not a type.
<Select id="lang" value={{ bind: "settings.lang" }} onChange="lang-changed">
<Option value="es"><Text>es</Text></Option>
<Option value="en"><Text>en</Text></Option>
</Select>El <Select> es el caso que merece leerse dos veces. Es un composite
aplanado, y la razón de que pudiera serlo es que la selección es un único
valor: el grupo "exclusive-check" ya existía, así que lo que el formato tuvo
que ganar para tener un desplegable fue el
popover — la regla de que una selección dentro
de un overlay anclado lo cierra. El botón cerrado enseña el valor a través
de un <Text> enlazado al mismo path, porque la IR no tiene expresiones y no
hay nada con lo que buscar una etiqueta: escribe los textos que se ven como si
fueran los valores, cuando son para que los lea el jugador.