Skip to content
zabloo

Container

The box that groups other nodes and lays them out. It draws nothing of its own — and one field on it gives a whole set of children a shared behavior.

primitivesince v1not focusable

A Container is a box that holds other nodes and decides where they sit. It has no content and no size of its own: it is as big as what is inside it, or as big as the space its parent hands it. Picture a settings panel — the card, its title row, the tab bar and each tab’s contents are all Containers, nested. You reach for one whenever something needs grouping, spacing or a direction, which in practice is most of a screen.

container-tabs.viewIR v1
Viewport

Viewport: 960 × 340

A settings panel with two tabs, Audio and Video: the Audio tab is selected and highlighted in purple, and under the bar its panel lists Master volume 80, Music 45 and Subtitles On. The Video panel is not shown while Audio is the selected tab.
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, Row, Tab, Tabs, Text } from "@zabloo/react";


/**
 * One "setting: value" line: a Row, and the flex pass doing all the work.
 *
 * `width` plus `grow` is what lets the panel below wrap them (ZAB-153): the
 * width is the base a wrapping row measures against, and `grow` shares out
 * whatever the line it lands on has left. Three across a desktop, two on a
 * tablet, one on a phone — decided by arithmetic, not by a breakpoint.
 */
function Line({ name, value }: { name: string; value: string }) {
  return (
    <Row
      layout={{
        width: 240,
        grow: 1,
        padding: "{space.3}",
        justify: "space-between",
        align: "center",
      }}
      style={{ background: "{color.slot}", radius: "{radius.md}" }}
    >
      <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>{name}</Text>
      <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>{value}</Text>
    </Row>
  );
}

export default function ContainerTabs() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it instead would pin the panel to its content and no
      // change of viewport could reach it (ZAB-153).
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        // `align: "stretch"` is what gives the children the panel's full width;
        // without it they measure to their content and `space-between` above has
        // no leftover space to distribute.
        layout={{ 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}" }}>Settings</Text>

        {/* One `group` field, and the SDK shows exactly one panel. A `<Tab>`'s
            own props dress its BAR BUTTON — the active one through
            `states.selected`, a state the group derives. */}
        <Tabs
          selected={0}
          bar={{ layout: { gap: "{space.1}" } }}
          // The group is a Container like any other: without `stretch` the
          // panel below would measure to its own content instead of taking the
          // width the settings card offers it.
          layout={{ gap: "{space.3}", align: "stretch" }}
        >
          <Tab
            variant="secondary"
            layout={{ width: 104, padding: "{space.2}", justify: "center", align: "center" }}
            states={{
              selected: {
                style: { background: "{color.brand-soft}", borderColor: "{color.brand}" },
              },
            }}
            label={<Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Audio</Text>}
            // The surface moved to the lines themselves: laid side by side on a
            // wide viewport, one shared background reads as a single strip of
            // text rather than as three settings.
            panel={{ layout: { gap: "{space.2}", align: "stretch" } }}
          >
            <Row layout={{ wrap: true, gap: "{space.3}", align: "start" }}>
              <Line name="Master volume" value="80" />
              <Line name="Music" value="45" />
              <Line name="Subtitles" value="On" />
            </Row>
          </Tab>

          <Tab
            variant="secondary"
            layout={{ width: 104, padding: "{space.2}", justify: "center", align: "center" }}
            states={{
              selected: {
                style: { background: "{color.brand-soft}", borderColor: "{color.brand}" },
              },
            }}
            label={<Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Video</Text>}
            // The surface moved to the lines themselves: laid side by side on a
            // wide viewport, one shared background reads as a single strip of
            // text rather than as three settings.
            panel={{ layout: { gap: "{space.2}", align: "stretch" } }}
          >
            <Row layout={{ wrap: true, gap: "{space.3}", align: "start" }}>
              <Line name="Resolution" value="1920 × 1080" />
              <Line name="Quality" value="High" />
              <Line name="Fullscreen" value="On" />
            </Row>
          </Tab>
        </Tabs>
      </Column>
    </Column>
  );
}
Every box here is a Container — the card, its header, the tab bar, each panel. Press Run and pick Video: watch a whole panel swap for another, then read the IR tab and notice that nothing in it says 'tab'. All the envelope declares is that these children belong to one 'exclusive-select' group.

The two tables below are nearly the same one, and that is worth noticing rather than hiding: what you write and what ships are normally two layers, and a Container is the node with the least layer on top of it. <Row>, <Column>, <Tabs> and <Accordion> are all this node with a direction or a group filled in for you — which is exactly why they cost the format nothing.

Authoring props

What you write in @zabloo/react.

PropTypeDefaultDescription
group"exclusive-open" | "exclusive-select" | "exclusive-check"absentCross-child behavior the SDK enforces.
selectednumber0Initially selected index of an "exclusive-select" group.
valueBindable<string | number>absentSelected value of an "exclusive-check" group.
onChangestringabsentNamed action fired when an "exclusive-check" group's selection moves.
childrenReactNodeabsentAny nodes.

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
group"exclusive-open" | "exclusive-select" | "exclusive-check"absentThe behavior. Ignored by an SDK that does not know it.
selectednumber0Initial index of an "exclusive-select" group. Ignored otherwise.
valueBindable<string | number>absentSelection of an "exclusive-check" group. Ignored otherwise.
onChangestringabsentNamed action, fired when an "exclusive-check" selection moves.
childrenZNode[][]Ordinary flow children.

Behavior

States

disabled only — and a Container is the usual place to declare it, because disabled inherits: the player finds a whole section switched off from one prop up here, and each control inside it, labels included, still dresses itself through its own states.disabled.

Focusable

No, and nothing the player does reaches it: a box is never hovered, pressed or focused, so no other states.* on one ever applies. Its children keep theirs.

Layout

The flex pass, and nothing else: direction, justify, align, gap, padding, wrap. A Container has no intrinsic size — it is as big as its children, its layout or the space a parent gives it.

Actions

onChange, and only as an "exclusive-check" group: the player picks an option → the group’s value moves → the game hears the name. That selection is the one thing a Container ever holds, so it is the one thing it can report. There is no onClick — a box is not pressable, and a pressable box is a Button.

Degradation

On an older SDK

On an older SDK the tab bar still shows, but every panel is stacked underneath it at once instead of one at a time.

A Container is itself the fallback — an unknown node type renders as one, which is why content newer than the SDK reading it degrades into a layout rather than into nothing. What can be older here is the group: an SDK that does not know 'exclusive-select' lays the children out as ordinary siblings. Usable, and visibly not what you authored.

AudioVideo
Audio panel

AudioVideo
Audio panelVideo panel

Group behaviors

An accordion, a tab strip and a radio group are not node types here. Each is a composite — it flattens to primitives while you author it — and the behavior it needs across its children is declared with one field the SDK implements generically: one behavior, one state it governs, no id wiring anywhere in the JSON.

That is what makes the closed set of thirteen affordable. In a format that grew by name those three would be three node types; here they are three values of group.

BehaviorCompositeState it governs
"exclusive-open"Accordionopen of the child Collapses
"exclusive-select"Tabsselected — an index
"exclusive-check"RadioGroup, Selectchecked of the descendant Toggles

exclusive-open

When a child Collapse opens, its siblings close. Nothing else changes: each one keeps its own header, its own content and its own initial open. An older SDK leaves independent collapses, any number of them open at once.

exclusive-select

Exactly one child is shown at a time, and the contract is positional:

  • children[0] is the bar, and its children are the tab buttons.
  • children[1..n] are the panels, one per button, in bar order.

Selecting index i puts children[i + 1] in layout — its siblings leave it, with display:none semantics — and gives bar button i the selected state. selected on the group is the initial index; the runtime selection belongs to the SDK, and the game moves it through the host channel.

exclusive-check

One descendant Toggle is checked, identified by value rather than by position: value on the group is the selection, value on each Toggle is its option. A toggle is checked while the two are equal, and tapping one writes its own value into the group’s — into the game’s data, when that is a binding. The checked state of a grouped toggle is derived, never stored per node.

onChange belongs to the group

The group owns the value, so it is the node that can say the selection moved; a Toggle’s own onChange only ever says this one was tapped, which is not the question a dropdown asks. Declare both and both fire, the option’s first. Like every action in v1 it carries no value of its own — the value comes back on the data channel, the leg that exists for exactly that.

The selection is ONE value, which is why the same behavior backs both a radio group and a dropdown without either needing a mechanism of its own.

Composition

Five of the names @zabloo/react exports are this node with something filled in. None of them reaches the IR.

  • <Row> / <Column> — use these instead of writing direction by hand, which is nearly always.
  • <Accordion> — use it when only one section of a list should be open at a time, like a long settings page.
  • <Tabs> — use it when several panels compete for the same rectangle and the player picks one.
  • <Badge> — use it for a count that comes from the game, such as unread mail.
// Row and Column: a Container with a direction. Your own layout still wins.
<Row layout={{ gap: 8, align: "center" }}>…</Row>
<Column layout={{ gap: 4 }}>…</Column>

// Accordion: group: "exclusive-open". Its children should be Collapses.
<Accordion layout={{ gap: 8 }}>
<Collapse open={false}><Text>Audio</Text><Text>…</Text></Collapse>
<Collapse open={false}><Text>Video</Text><Text>…</Text></Collapse>
</Accordion>

// Tabs: group: "exclusive-select", with the positional contract built for you.
// A Tab's own props style its BAR BUTTON — the active one through states.selected.
<Tabs selected={0} bar={{ layout: { gap: 4 } }}>
<Tab label="Audio"><Text>Audio settings</Text></Tab>
<Tab label="Video" panel={{ layout: { padding: 12 } }}><Text>Video settings</Text></Tab>
</Tabs>

// Badge: a pill Container around a bound Text. It needs no IR of its own,
// because Text has been bindable since v1.
<Badge count={{ bind: "inbox.unread" }} />

There is no “hide at zero” on a Badge, and the reason generalizes: the IR has no expressions. Bind visible to a flag the game owns when something should disappear.

<Tabs> is worth one more line, because it is the one composite that builds a positional contract rather than filling in a field: each <Tab> contributes a bar button and a panel, in order, so the author never counts children. A <Tab> never renders itself — <Tabs> reads its props at authoring time.