Skip to content
zabloo

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.

toggle-controls.viewIR v1
An Audio and video panel with a purple checkbox carrying a white mark labelled Sound effects, a switch turned on labelled Fullscreen, and under the heading Quality a pair of radio options, Low and High, with High selected.
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 { 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 &amp; 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>
  );
}
A checkbox, a switch and two radios — four instances of one node type. Press Run and tap them, then watch what each tap costs: the first two flip a boolean of their own, while the radios move a single shared value, so choosing one visibly releases the other with no message passing between them.

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.

PropTypeDefaultDescription
checkedBindable<boolean>falseInitial state, or a read/write binding.
onChangestringabsentNamed action fired after every change.
sizenumber22Indicator size in px — the box side, or the switch track height.
childrenReactNodeabsentThe 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.

PropTypeDefaultDescription
checkedBindable<boolean>falseInitial state, or a read/write binding.
valuestring | numberabsentThis option's value inside an "exclusive-check" group.
onChangestringabsentNamed action fired after every change.
childrenZNode[][]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:

SlotShown
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, plus hover, pressed and focused — and disabled, its own or inherited. A disabled toggle keeps its checked state: what it holds and whether the player may change it are two different statements, which is why disabled merges 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: checked is 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’s value and never stored per node — tapping it writes its own value into the group’s.

Actions

The player taps it → the SDK flips the value and writes it into the data → onChange fires with the named action you gave it. It fires after every change however it was caused, the game’s own SetChecked included. 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.

With a transition, the indicators crossfade

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

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.

Sound effects

Sound effects

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.