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.
Viewport: 960 × 380

- 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>
);
}{
"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": {
"overlay-modal": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "stretch",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"id": "panel",
"layout": {
"direction": "column",
"padding": "{space.5}",
"gap": "{space.3}",
"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": "Main menu"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.sm}"
},
"text": "Everything under the backdrop is still there — it is unreachable, not gone."
},
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.2}"
},
"children": [
{
"type": "Button",
"id": "continue",
"layout": {
"grow": 1,
"height": 38,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "continue",
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Continue"
}
]
},
{
"type": "Button",
"id": "quit",
"layout": {
"width": 96,
"height": 38,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "quit-ask",
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.sm}"
},
"text": "Quit"
}
]
}
]
},
{
"type": "Overlay",
"id": "confirm-quit",
"visible": {
"bind": "ui.confirmQuit"
},
"layout": {
"direction": "row",
"justify": "center",
"align": "center",
"padding": "{space.6}"
},
"style": {
"background": "#000000a6"
},
"transition": {
"duration": "{motion.fast}"
},
"modal": true,
"onDismiss": "quit-cancelled",
"children": [
{
"type": "Container",
"layout": {
"padding": "{space.5}",
"gap": "{space.4}",
"width": 300,
"align": "stretch"
},
"style": {
"background": "{color.raised}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.md}"
},
"text": "Quit the game?"
},
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.2}"
},
"children": [
{
"type": "Button",
"layout": {
"grow": 1,
"height": 36,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.brand-strong}",
"radius": "{radius.md}",
"color": "{color.on-brand}"
},
"states": {
"hover": {
"style": {
"background": "{color.brand-hover}"
}
},
"pressed": {
"style": {
"background": "{color.brand-pressed}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"autofocus": true,
"onClick": "quit-confirm",
"children": [
{
"type": "Text",
"style": {
"color": "{color.on-brand}",
"fontSize": "{text.sm}"
},
"text": "Quit"
}
]
},
{
"type": "Button",
"layout": {
"grow": 1,
"height": 36,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "quit-cancelled",
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Cancel"
}
]
}
]
}
]
}
]
}
]
}
]
}
}
}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.
| Prop | Type | Default | Description |
|---|---|---|---|
| position | one of the nine placements | per component | Placement on the layer, or around the anchor when there is one. |
| anchor | string | absent | id of the node to hang from. |
| offset | Dim | 8 | Distance from the anchor's edge. Ignored without an anchor. |
| trigger | "manual" | "hover" | "press" | per component | What puts it in the layer. |
| autoCloseMs | number | per component | Self-dismiss delay. |
| onDismiss | string | absent | Named action fired on a dismiss request. |
| panel | ContainerProps | absent | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| modal | boolean | true | Blocks input below and confines focus to this subtree. |
| z | number | 0 | Explicit stacking within the layer; ties break by document order. |
| onDismiss | string | absent | Named action fired on a dismiss request. |
| autoCloseMs | number | absent | Milliseconds before the overlay requests its own dismissal. |
| anchor | OverlayAnchor | absent | Places the content against another node's rect: id, at, offset, trigger. |
| children | ZNode[] | [] | The content. |
The layer
The overlay’s own rect IS the view rect. So:
layout.justify/align/paddingposition the content — a centred modal, a bottom-right toast, an inset that keeps it off the screen edges.layout.width/heighton the overlay itself are ignored: a layer is not sized. Size the child instead.style.backgroundis 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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | id of the node in this view to hang from. |
| at | AnchorAt | "top" | Preferred placement around the anchor. |
| offset | Dim | 8 | Distance 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
manual—visibleand 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.
- Pressing the anchor toggles it. The press that opens it is the one that
closes it. The anchor’s own
onClickstill fires — opening is behavior, never a substitute for the declared action. - A dismiss request closes it — the same path
onDismisshangs off. - 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. - 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
disabledonly, and only its own — anOverlayis 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
transitionfades 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 aftervisiblewent false.visibleitself 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
falsethrough the binding that opened it →onDismissfires 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 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.
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>