Toggle
A control that is either on or off. The checkbox, the switch and the radio are the same node — what differs is how it is dressed and whether it sits in a group.
primitivesince v1focusable
A Toggle holds one of two states: on or off. You reach for it whenever a
setting is a yes-or-no, and whenever the player picks exactly one option out of a
short list. Picture a settings screen: the Sound effects checkbox is a Toggle
on its own, holding a boolean the game reads; the Low / Medium / High row under
it is three more of them sharing a single value, which is how picking High
turns Low off without either of them knowing the other exists.

- 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 { 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"
}
]
}
]
}
]
}
]
}
}
}The two tables below are further apart than on any other page, and that gap is
the whole reason the component layer exists: box, checkedBox, mark, track
and knob are not props of this node. They are the styles of the two
children the component builds for you, and what ships is those children — never
the words.
Authoring props
There is no <Toggle> component. The slots below are positional, and the
four controls that emit this node are the one place that convention is written
down — which is why you never fill them by hand. They share these props.
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | Bindable<boolean> | false | Initial state, or a read/write binding. |
| onChange | string | absent | Named action fired after every change. |
| size | number | 22 | Indicator size in px — the box side, or the switch track height. |
| children | ReactNode | absent | The label. Tapping it toggles too. |
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. <Checkbox> adds box, checkedBox and mark; <Switch>
adds track, checkedTrack and knob; <Radio> requires a value.
IR props
What ships to the game, after the authoring layer is gone.
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | Bindable<boolean> | false | Initial state, or a read/write binding. |
| value | string | number | absent | This option's value inside an "exclusive-check" group. |
| onChange | string | absent | Named action fired after every change. |
| children | ZNode[] | [] | Positional slots — checked indicator, unchecked indicator, label. |
Indicator slots
Paint is implicit — there is no draw-command layer — so the tick or the knob is composed, not drawn by a new primitive:
| Slot | Shown |
|---|---|
children[0] | only while checked |
children[1] | only while unchecked |
children[2..] | always — the label |
The slots enter and leave the layout with display:none semantics, the same
single hiding mechanism a Collapse uses for its content. A switch moves its
knob by swapping two justify-ed slots; a checkbox shows its mark. Each slot
paints the whole indicator as it looks in that state, which is what keeps the
rule that nothing styles a descendant by state.
The two indicator slots share one box in the flow: they are measured as one item, taking the larger of the two, and both receive the same rect. That is what lets them crossfade without the label moving.
Behavior
- States
checked, plushover,pressedandfocused— anddisabled, its own or inherited. A disabled toggle keeps itscheckedstate: what it holds and whether the player may change it are two different statements, which is whydisabledmerges last.- Focusable
Yes, unless
disabled. The player taps it, presses Enter while it is focused, or presses gamepad A — and none of the three reaches it while it is disabled.- Value
Standalone it carries a boolean:
checkedis either a literal initial value or a read/write binding the SDK writes into its data store. Inside an"exclusive-check"group it is derived from the group’svalueand never stored per node — tapping it writes its ownvalueinto the group’s.- Actions
The player taps it → the SDK flips the value and writes it into the data →
onChangefires with the named action you gave it. It fires after every change however it was caused, the game’s ownSetCheckedincluded. Inside a group it fires only when this option takes the selection: a radio never turns itself off, so the one that loses it says nothing.
The SDK tweens a 0..1 checked progress and multiplies each slot’s opacity by it —
children[0] fading in as children[1] fades out. That is what the shared box is for: the
label never moves while the mark appears or the knob is swapped. It is component behavior
driving the motion engine with endpoints it computes, not an animatable prop, and it composes
with whatever opacity the slot already declares.
Degradation
On an older SDK both halves of the control show at once — the tick and the empty box, side by side — and tapping either does nothing.
As a Container, and this one is worth picturing: BOTH indicator slots are in the layout, plus the label, because nothing is hiding one of them. The control is inert. Everything the author wrote is on screen; what is missing is the rule that only one of the two belongs there.
How the game hears this
The binding carries the value; the action carries the moment. A toggle talks on both legs at once, and they answer different questions. The binding is the boolean: the SDK writes the new one into the data store, and everything reading that path re-lays out. The action is the event — this changed, now — and like every action in v1 it carries no value of its own. A settings screen that saves on exit needs only the binding; one that applies immediately hangs on the action.
The other direction is SetChecked(id, checked), and it is not a state poke: it
runs the same path the player’s finger does, so it fires onChange — and the
group’s, inside one — exactly as a tap would.
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);
}
}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
Four names, one node. What changes between them is which slots the component builds and whether it sits in a group.
<Checkbox>— use it for an independent yes-or-no in a list of settings.<Switch>— the same thing, dressed as a track and a knob; use it when the setting takes effect immediately.<RadioGroup>— use it when a player must pick one of a few options that all fit on screen at once.<Select>— use it for that same single choice when the list is too long to show, or the space too tight.
// 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>A <Select> is the case worth reading twice. It is a flattened composite,
and the reason it could be one is that the selection is a single value: the
"exclusive-check" group already existed, so what the format had to gain for a
dropdown was the popover — the rule that a
selection inside an anchored overlay closes it. The closed button shows the
value through a <Text> bound to the same path, because the IR has no
expressions and there is nothing to look a label up with: author the display
strings as the values when they are for the player to read.