Skip to content
zabloo

Overlay

Content that paints above the rest of the screen — dialogs, toasts, tooltips, dropdowns. You declare it where the UI that opens it lives, and it never pushes its siblings around.

primitivesince v1not focusable

An Overlay is content that paints above everything else on the screen: a confirmation dialog, a toast, a tooltip, the open list of a dropdown. You write it inside the UI that opens it, but it is lifted onto a layer of its own, so nothing around it moves to make room. Picture a pause menu with a Quit? dialog: the dialog is declared inside the menu panel, one boolean the game owns decides whether it is there, and while it is, nothing underneath it can be clicked.

overlay-modal.viewIR v1
Viewport

Viewport: 960 × 380

A Main menu panel with Continue and Quit buttons, dimmed under a dark backdrop, and over it a centred dialog asking Quit the game? with a filled Quit button beside a Cancel one.
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 { Button, Column, Modal, Row, Text } from "@zabloo/react";

export default function OverlayModal() {
  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"
        layout={{ padding: "{space.5}", gap: "{space.3}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>Main menu</Text>
        <Text style={{ color: "{color.faint}", fontSize: "{text.sm}" }}>
          Everything under the backdrop is still there — it is unreachable, not gone.
        </Text>
        <Row layout={{ gap: "{space.2}" }}>
          <Button
            id="continue"
            variant="secondary"
            onClick="continue"
            layout={{ grow: 1, height: 38, justify: "center", align: "center" }}
          >
            <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Continue</Text>
          </Button>
          <Button
            id="quit"
            variant="secondary"
            onClick="quit-ask"
            layout={{ width: 96, height: 38, justify: "center", align: "center" }}
          >
            <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Quit</Text>
          </Button>
        </Row>

        {/* The component IS the backdrop — its own style paints it — and `panel`
            styles the card inside. That is why there is no `backdrop` prop. */}
        <Modal
          id="confirm-quit"
          visible={{ bind: "ui.confirmQuit" }}
          onDismiss="quit-cancelled"
          transition={{ duration: "{motion.fast}" }}
          style={{ background: "#000000a6" }}
          layout={{ padding: "{space.6}" }}
          panel={{
            layout: { width: 300, padding: "{space.5}", gap: "{space.4}", align: "stretch" },
            style: {
              background: "{color.raised}",
              radius: "{radius.lg}",
              borderWidth: "{border.hairline}",
              borderColor: "{color.line-strong}",
            },
          }}
        >
          <Text style={{ color: "{color.text}", fontSize: "{text.md}" }}>Quit the game?</Text>
          <Row layout={{ gap: "{space.2}" }}>
            {/* `autofocus` takes the focus when the modal opens; the SDK gives
                it back to whatever held it when the modal closes. */}
            <Button
              variant="primary"
              onClick="quit-confirm"
              autofocus
              layout={{ grow: 1, height: 36, justify: "center", align: "center" }}
            >
              <Text style={{ color: "{color.on-brand}", fontSize: "{text.sm}" }}>Quit</Text>
            </Button>
            <Button
              variant="secondary"
              onClick="quit-cancelled"
              layout={{ grow: 1, height: 36, justify: "center", align: "center" }}
            >
              <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Cancel</Text>
            </Button>
          </Row>
        </Modal>
      </Column>
    </Column>
  );
}
The dialog is declared inside the panel it covers — look at the panel behind it and note that nothing shifted to accommodate it. Press Run and then Escape: the SDK writes false back through the same binding that opened it, which is why closing needs no mechanism of its own.

The SDK collects every visible Overlay of the view into one layer painted above the whole tree, sorted by (z, document order).

The two tables below barely overlap, and position is the reason. What you write is one of nine names; what ships is the layout.justify/align/padding those names resolve into — because an overlay’s placement was always just a layout on a rect the size of the view.

Authoring props

<Overlay> emits this node and nothing else: it has no positional slots, so there is no convention a component would have to own. The three composites below are the ready-made shapes.

PropTypeDefaultDescription
positionone of the nine placementsper componentPlacement on the layer, or around the anchor when there is one.
anchorstringabsentid of the node to hang from.
offsetDim8Distance from the anchor's edge. Ignored without an anchor.
trigger"manual" | "hover" | "press"per componentWhat puts it in the layer.
autoCloseMsnumberper componentSelf-dismiss delay.
onDismissstringabsentNamed action fired on a dismiss request.
panelContainerPropsabsentThe card, pill or bubble the content sits in — a whole Container's props.

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
modalbooleantrueBlocks input below and confines focus to this subtree.
znumber0Explicit stacking within the layer; ties break by document order.
onDismissstringabsentNamed action fired on a dismiss request.
autoCloseMsnumberabsentMilliseconds before the overlay requests its own dismissal.
anchorOverlayAnchorabsentPlaces the content against another node's rect: id, at, offset, trigger.
childrenZNode[][]The content.

The layer

The overlay’s own rect IS the view rect. So:

  • layout.justify / align / padding position the content — a centred modal, a bottom-right toast, an inset that keeps it off the screen edges.
  • layout.width / height on the overlay itself are ignored: a layer is not sized. Size the child instead.
  • style.background is the backdrop. A translucent colour dims what it covers; no background at all makes a transparent layer. Paint stays implicit, with no extra field.

Opening and closing

visible is the only mechanism

Exactly as everywhere else in the format. A hidden overlay contributes no layer, no backdrop and no input blocking — so a game opens a dialog by moving one boolean, and a dismiss request (Escape, gamepad B, a tap on a modal’s backdrop, or autoCloseMs running out) is the SDK writing false back through that same binding and firing onDismiss. That is what lets closing be expressed without a mechanism of its own: the game’s data is the single source of truth for what is open.

autoCloseMs is a plain number, not a Dim: it is a behavior timeout, not motion, and nothing about it is themeable the way a transition’s duration is. Its clock starts when the overlay enters the layer and resets if it leaves and returns.

Modality

modal: true (the default) does two things that are really one statement — this is the only thing you can interact with right now:

  • Input capture. Hit-testing runs the layer first, top-down, and a modal overlay captures the point: everything below it, lower overlays included, is unreachable.
  • Focus trap. Directional navigation is confined to this subtree, and closing restores the focus to whatever held it before. The trap derives from modal — there is no second field.

modal: false — a toast, a tooltip — paints above but leaves the layer’s own rect inert: only its children take events, everything else passes through to the tree below.

Anchoring

With an anchor, the content is placed against another node’s rect instead of against the layer. This is the one piece of layout in v1 that is relative to a rect the node does not contain, which is why it is a field of its own.

PropTypeDefaultDescription
idstringid of the node in this view to hang from.
atAnchorAt"top"Preferred placement around the anchor.
offsetDim8Distance between the anchor's edge and the content.
trigger"manual" | "hover" | "press""manual"What puts it in the layer.

Fit is deterministic, with no field of its own. If the content does not fit on the preferred side and the opposite one has room, it flips; then it is clamped into the view, keeping the overlay’s own layout.padding as its margin from the edges. The layer placement is always emitted alongside an anchor, which is exactly what an SDK that predates anchoring renders.

A tooltip never points at nothing. If the anchor leaves layout — its visible went false, its tab panel closed — or is entirely clipped away by a ScrollView, the overlay leaves the layer too, with its exit fade. An id that resolves to no node is an authoring error rather than runtime state: the SDK warns (unknown-anchor) and falls back to the layer placement, so a typo degrades to a visible overlay rather than to silence.

Triggers

  • manualvisible and nothing else.
  • hover — shown while the anchor is hovered or focused. One value for both, because they are the same thing through different devices: on a pad, focus is hover, so a hint reaches a controller without a second mechanism.
  • press — the popover, below.

A hover or press trigger needs an anchor that takes input — a Button, a Toggle, a Slider, a Collapse header. Hover lights up exactly the focusable set, which is also what keeps the pointer and the gamepad seeing the same hints.

Popovers

trigger: "press" is the state no overlay owned before: visible could open one, but nothing in the IR could close it in response to something the player did inside it — which is exactly what a dropdown is. So the SDK owns an open flag per anchored overlay, keyed by the relation, the way it already owns Collapse.open and Toggle.checked.

  1. Pressing the anchor toggles it. The press that opens it is the one that closes it. The anchor’s own onClick still fires — opening is behavior, never a substitute for the declared action.
  2. A dismiss request closes it — the same path onDismiss hangs off.
  3. A selection inside it closes it. When an "exclusive-check" group inside the popover takes a new value, the popover closes: choosing is the gesture that ends it. This is what makes <Select> expressible as a composite rather than a primitive.
  4. Opening focuses the selection — the checked option of that group, so the list opens where the player left it; failing that, the subtree’s autofocus. Closing gives the focus back to the anchor.

An SDK that predates a trigger value reads it as manual, so the dropdown sits open on the layer where its anchor put it: a visible, inert list rather than a control that never appears.

Behavior

States

disabled only, and only its own — an Overlay is where the inheritance stops, being the top of its own input scope. A modal declared inside a disabled panel stays operable and dismissable, which is the behavior a confirmation dialog over a locked screen needs.

Focusable

No, not itself. Its children keep their own states and their own focus; what the overlay contributes is the trap around them — the player’s next arrow press cannot leave the dialog.

Motion

A transition fades its layer presence: the SDK tweens the whole entry’s opacity as it enters and leaves, so a closing overlay stays on screen for exactly one duration after visible went false. visible itself never animates — an overlay on its way out is only pixels, and input, focus trap and timers all read the live layer, which it has already left.

Actions

The player presses Escape, gamepad B, or taps the backdrop → the SDK writes false through the binding that opened it → onDismiss fires alongside that write. One request, one action, and the closing has already happened by the time the game hears about it.

Degradation

On an older SDK

On an older SDK the dialog shows in the flow, where it was written — in the middle of the panel, with no dimmed backdrop and nothing blocked behind it.

As a Container, and this one is worth knowing: the content lands IN THE FLOW instead of on a layer. It shows, in the wrong place, with no backdrop and no capture. A dialog degrades into a section of the page, which is readable and reachable even though it is not what you drew.

Quit?

Main menuQuit?

How the game hears this

The binding says whether it is open; the action says the player asked to close. They arrive together and neither replaces the other: the SDK has already written false by the time onDismiss reaches the game, so a handler never has to close anything by hand — it only has to decide what closing meant.

using UnityEngine;
using Zabloo;

[RequireComponent(typeof(ZablooDocument))]
public sealed class PauseMenu : MonoBehaviour
{
  ZablooDocument _doc;

  void Start()
  {
      _doc = GetComponent<ZablooDocument>();
      _doc.OnAction += OnZablooAction;
  }

  void OnDestroy()
  {
      if (_doc != null) _doc.OnAction -= OnZablooAction;
  }

  void OnZablooAction(string action)
  {
      // The game opens the dialog by moving the boolean it is bound to.
      if (action == "quit-ask") _doc.SetData("ui.confirmQuit", true);

      // Escape, gamepad B and the backdrop all land here — and the SDK has
      // already written false back, so there is nothing to close by hand.
      if (action == "quit-cancelled") ResumeGame();

      if (action == "quit-confirm") Application.Quit();
  }
}

Composition

Three ready-made shapes, all the same node with different defaults. The z defaults are a convention of the authoring layer, not a taxonomy in the format: a toast above a modal, a tooltip above both.

  • <Modal> — use it when the player must answer before doing anything else: a confirmation, a level-up screen, an error that has to be acknowledged.
  • <Toast> — use it for news that needs no answer, since it is non-modal and closes itself: Game saved, Item sold.
  • <Tooltip> — use it for a hint attached to a control, where showing it while the player is on that control is the whole trigger.
// Modal — position: "center", modal: true, z: 0. The component IS the
// backdrop (its style paints it), which is why there is no backdrop prop.
<Modal visible={{ bind: "ui.confirmQuit" }} onDismiss="quit-cancelled" transition={{ duration: 150 }}>
<Text>Quit the game?</Text>
<Row layout={{ gap: 8 }}>
  <Button onClick="quit-confirm" autofocus><Text>Quit</Text></Button>
  <Button onClick="quit-cancelled"><Text>Cancel</Text></Button>
</Row>
</Modal>

// Toast — position: "bottom", modal: false, autoCloseMs: 3000, z: 10.
// Non-modal, so the player keeps using what is underneath.
<Toast visible={{ bind: "ui.saved" }} onDismiss="toast-closed">Game saved</Toast>

// Tooltip — position: "top", modal: false, z: 20, and trigger: "hover"
// WHEN IT HAS AN ANCHOR: a hint about a control shows while you are on it.
<Button id="jump-btn" onClick="jump"><Text>Jump</Text></Button>
<Tooltip anchor="jump-btn" position="top">Press A to jump</Tooltip>