Skip to content

UI

The @yagejs/ui package provides screen-space UI powered by Yoga flexbox layout. Build menus, HUDs, and overlays with a fluent builder API.

For a React-based alternative, see UI (React).

import { UIPlugin } from "@yagejs/ui";
engine.use(new UIPlugin());

The plugin depends on @yagejs/renderer.

UIPanel is the root UI component. Add it to an entity to create a UI tree.

import { UIPanel, Anchor } from "@yagejs/ui";
const ui = this.spawn("hud");
ui.add(new Transform());
ui.add(new UIPanel({
anchor: Anchor.TopLeft,
direction: "column",
gap: 8,
padding: 16,
background: { color: 0x000000, alpha: 0.7, radius: 8 },
}));

Panel options:

PropertyTypeDefaultDescription
anchorAnchorScreen-space position
offset{ x, y }Pixel offset from anchor
direction"row" | "column""column"Flex direction
gapnumberSpace between children
paddingnumber | PaddingInner padding
alignItemsstringCross-axis alignment
justifyContentstringMain-axis alignment
overflow"visible" | "hidden""visible"Overflow behavior
backgroundBackgroundOptionsColor or texture background
layerstringRender layer name
visiblebooleantrueInitial visibility

YAGE’s UI is Yoga-flexbox-based and keeps Yoga’s raw defaults — most notably flexShrink: 0. A flex child reports its natural main-axis size and overflows a too-small row or column rather than being crushed to fit.

This deliberately differs from the web’s flexShrink: 1. The web can shrink everything safely because each item also has a min-width: auto content floor that stops it collapsing past its content; Yoga has no such floor, so a global flexShrink: 1 would crush fixed-size siblings (icons, fixed-width buttons) and collapse scroll content — a ScrollView’s content must exceed its viewport for there to be anything to scroll. So shrinking and wrapping are opt-in:

  • flexShrink: 1 on a child lets it give space back when the line is too small; a Text then re-wraps inside its slot. An explicit value always wins.

  • flex: <number> is shorthand for flexGrow: <n> + flexShrink: 1 + flexBasis: 0 (the CSS flex: <n> rule). Reach for it on a child that should fill the remaining space — classically the text column sitting between a fixed icon and a fixed button. Because it sizes from a 0 basis rather than its content width, it won’t shove its siblings, and its text wraps cleanly. Prefer flex: 1 to flexGrow: 1 here: flexGrow: 1 on its own keeps flexBasis: auto (the content’s natural width), so a long label still overflows and pushes the neighbours.

// Fixed icon · growing + wrapping text column · fixed button — the common row.
row.panel({ width: 16, height: 16 }); // fixed (flexShrink 0)
const col = row.panel({ flex: 1, direction: "column" }); // fills & wraps
col.text("a long label that wraps within the column");
row.button("Buy", { width: 68, onClick: () => {} }); // fixed

Note that text only wraps once a width constraint reaches it — some ancestor must have a definite width (an explicit width, or a flex/flexShrink child that’s been shrunk to a definite size). The UI root is laid out shrink-to-content and is not clamped to the viewport, so intentionally large UIs (skill trees, world maps) can exceed the screen; if you want a top-level panel to bound and wrap its contents, give it an explicit width.

  • A dev-mode warning catches the leftovers. When an in-flow child still overflows its container, YAGE logs a one-time console.warn pointing at the offending node. It’s compiled out of production builds and stays quiet for intentional overflow (overflow: "hidden" containers, position: "absolute" children, and scroll views).

When a warning fires, fix it by giving the container more room, setting maxWidth/maxHeight, marking the child flexShrink: 1 / flex: <n> so it gives space back and wraps, or using truncate: "clip" | "ellipsis" on the text (see Text) or button.

The Anchor enum positions panels relative to the screen:

import { Anchor } from "@yagejs/ui";
Anchor.TopLeft Anchor.TopCenter Anchor.TopRight
Anchor.CenterLeft Anchor.Center Anchor.CenterRight
Anchor.BottomLeft Anchor.BottomCenter Anchor.BottomRight

Add an offset to fine-tune position:

new UIPanel({
anchor: Anchor.TopRight,
offset: { x: -16, y: 16 },
})

A panel’s position comes from two independent choices: which layer it lives on (screen-space HUD or world-space overlay) and its positioning option (viewport-anchored or Transform-driven).

anchor resolves against the viewport (virtualSize), offset is a pixel nudge. This is the classic HUD / menu behavior and the default for a reason — it’s what HUDs want.

The panel’s root container is positioned at entity.get(Transform).worldPosition in the target layer’s local coord space, and anchor is reinterpreted as the pivot on the panel itself:

  • Anchor.Center → panel’s center sits at the Transform.
  • Anchor.BottomCenter → panel’s bottom-center sits at the Transform (the natural “hovers above this entity” primitive for nameplates and health bars).

offset is still a pixel nudge, applied after the pivot. The entity must have a Transform or the panel throws at add time.

This option is orthogonal to the layer’s space:

  • Screen-space layer + positioning: "transform": pair with ScreenFollow from @yagejs/renderer. ScreenFollow writes camera.worldToScreen(target) + offset to the Transform each frame (the offset is in screen pixels, applied after projection), so the UI tracks a target entity but stays axis-aligned and constant-size regardless of camera zoom or rotation. This is the canonical billboard pattern.
  • World-space layer + positioning: "transform": the UI is pinned to a real world coordinate and scales / rotates with the camera like any other world object. Useful for genuinely diegetic UI — a sign in the world, an LED on a machine.

The common “nameplate above an enemy” pattern:

import { ScreenFollow } from "@yagejs/renderer";
class Enemy extends Entity {
setup(params: {
x: number; y: number; label: string; camera: CameraEntity;
}) {
this.add(new Transform({ position: new Vec2(params.x, params.y) }));
this.add(new Health({ max: 100 }));
// Body, nameplate, and HP bar are all siblings under this entity.
// Parenting expresses "these belong to this enemy" structurally —
// so when the enemy is destroyed, cascade-destroy cleans the UI
// children up automatically. Positioning still flows through
// ScreenFollow for the UI siblings so they stay axis-aligned and
// constant-size under camera zoom/rotation, regardless of parenting.
this.spawnChild("body", EnemyBody, { color: 0xff6b6b });
this.spawnChild("nameplate", EnemyNameplate, {
target: this,
camera: params.camera,
label: params.label,
});
}
}
class EnemyNameplate extends Entity {
setup(params: {
target: Entity; camera: CameraEntity; label: string;
}) {
this.add(new Transform());
this.add(new ScreenFollow({
target: params.target,
camera: params.camera,
offset: new Vec2(0, -40), // 40 screen px above the target, at any zoom
}));
const panel = this.add(new UIPanel({
positioning: "transform",
anchor: Anchor.BottomCenter,
padding: 4,
background: { color: 0x000000, alpha: 0.6, radius: 4 },
}));
panel.text(params.label, { fontSize: 11, fill: 0xffffff });
}
}

The offset is in screen pixels — 40 world units above at zoom=1 and 40 screen pixels above at zoom=2 are different things, and screen-pixel offsets are what nameplates almost always want. See the world-ui example for a runnable demo with zoom and rotation controls.

Hover-driven floating bubbles for the imperative UI layer — no React required. UIPlugin provisions one top-most, screen-space floating overlay per scene; attachTooltip parents a bubble into it and anchors it to a trigger. The bubble draws above all other UI, escapes any <ScrollView> clip, flips to the opposite side and shifts along the cross axis to stay on-screen, and — because it re-anchors every frame against the trigger’s live geometry — stays glued to world-space / camera-transformed triggers like the ScreenFollow namecard above.

import { attachTooltip, PanelNode, UIText } from "@yagejs/ui";
// `anchor` is a root UIPanel (here the namecard) or any UIElement. It's read
// only for positioning; attachTooltip never wires hover onto it.
const tip = attachTooltip(panel, this.scene, {
placement: "top", // side, or side-align (e.g. "bottom-start"); default "top"
offset: 8, // px gap between trigger and bubble (default 6)
maxWidth: 200, // px; long content wraps + clamps to available space
// Headless: build (and style) the bubble yourself. Called once.
content: () => {
const card = new PanelNode({
padding: 6,
gap: 4,
background: { color: 0x111827, alpha: 0.95, radius: 6 },
});
card.addElement(new UIText({ children: "Goblin", style: { fontSize: 13 } }));
card.addElement(new UIText({ children: "HP 100 / 100", style: { fontSize: 11 } }));
return card;
},
});
// Activation is yours — wire it on hover (the common case). On a root UIPanel
// use setPointerHandlers; on a child element use its `update({ onHover })`.
panel.setPointerHandlers({ onHover: tip.setActive });
// When the namecard goes away, release the overlay slot:
tip.dispose();

attachTooltip builds the floating parts and hands back a { setActive, dispose } controller — it wires no input of its own, so it can’t clobber the anchor’s handlers. anchor may be a root UIPanel or any UIElement. You decide what shows the tooltip: set onHover — on a panel via panel.setPointerHandlers({ onHover: tip.setActive }), on a child element via its update({ onHover: tip.setActive }) — or drive it from focus, a long-press, or a programmatic call. Setting onHover replaces that single slot (which is what you want when the anchor has none); if it already handles hover, compose (onHover: (h) => { existing(h); tip.setActive(h); }). It’s headless like the React <Tooltip>: nothing is styled for you, so pass a background / padding on the content node. Own the returned dispose() (e.g. call it from a component’s onDestroy) to free the slot; setActive is a safe no-op afterwards, so a lingering hover wiring won’t touch a freed tooltip.

For fully custom popovers / menus, drop to the primitives: acquire a slot with scene._resolveScoped(FloatingOverlayKey).acquire() for a FloatingHandle, and position with the pure computePosition() engine (offset → flip → shift → size). The React <Tooltip> / useFloating build on this exact overlay — see the React UI guide.

Add text to a panel with the builder API:

const panel = entity.get(UIPanel);
const label = panel.text("Score: 0", {
fontSize: 24,
fill: 0xffffff,
fontFamily: "monospace",
});
// Update text later
label.setText("Score: 100");
label.setStyle({ fill: 0x00ff00 });

For a UIText directly (or via the React <Text>), two extra props control rasterisation:

import { UIText } from "@yagejs/ui";
// Crisp pixel-art text — draws pre-baked glyph quads instead of a
// blurry bilinear-sampled canvas texture. `bitmap: true` bakes (or looks
// up) the atlas from `style.fontFamily` at `style.fontSize`.
new UIText({ children: "SCORE", bitmap: true, style: { fontFamily: "monospace", fontSize: 12 } });
new UIText({ children: "READY", bitmap: true, style: { fontFamily: "PressStart", fontSize: 16 } });
// Per-text canvas resolution.
new UIText({ children: "HUD", resolution: window.devicePixelRatio });

Word-wrap and the truncate?: "clip" | "ellipsis" overflow modes work the same on the bitmap path as on canvas text.

For typewriter reveals, per-letter colour / wave, or staggered line entrances inside a panel, use UISplitText — the UI sibling of @yagejs/renderer’s SplitTextComponent. It lays the block out as one Yoga element and exposes chars / words / lines (per-glyph, per-word, per-line display objects) for you to animate. You drive those with the engine’s Tween / Process — it deliberately doesn’t ship its own animation API.

useSplitText() returns a [ref, controls] tuple: put ref on the element, then reach controls.chars / words / lines and run tweens whenever you like (an event handler, a timeout, an effect) rather than binding up front. controls.run schedules on a scene-scoped process queue, so animations pause with the scene and are cancelled on unmount — and on re-split, so a tween never writes to a destroyed glyph. Pair it with Tween.stagger to cascade a tween across the segments.

import { SplitText, useSplitText } from "@yagejs/ui-react";
import { Tween } from "@yagejs/core";
function GameOver({ label }: { label: string }) {
const [ref, split] = useSplitText();
const reveal = () => {
split.chars.forEach((c) => (c.alpha = 0));
// stagger each char's fade-in by 50ms; run() returns a cancel handle.
split.run(Tween.stagger(split.chars, (c) => Tween.to(c, "alpha", 1, 300), 50));
};
return (
<SplitText ref={ref} charAnchor={0.5} style={{ fontSize: 48 }} onPointerOver={reveal}>
{label}
</SplitText>
);
}

Imperatively (no React), new UISplitText({ children, charAnchor, … }) exposes the same chars / words / lines and an onSplit(cb) subscription that fires after each re-split.

const btn = panel.button("Start Game", {
width: 200, // optional — omit to shrink-to-content
height: 50, // optional — omit to shrink-to-content
background: { color: 0x4444aa, radius: 6 },
hoverBackground: { color: 0x5555cc, radius: 6 },
pressBackground: { color: 0x333388, radius: 6 },
textStyle: { fontSize: 18, fill: 0xffffff },
onClick: () => {
engine.scenes.push(new GameScene());
},
});
// Disable/enable
btn.setDisabled(true);
// Change label
btn.setText("Loading...");

Buttons support three background states: default, hover, and press.

Omitting width and/or height lets Yoga shrink the button to fit its content, with a small default padding around the label. Pass explicit dimensions when you need a fixed-size button (e.g. for a grid of equally-sized toolbar buttons).

For a fixed-size button whose label might be longer than the box — especially with variable-length (i18n) strings — pass truncate: "clip" | "ellipsis" to keep the label on a single line so it can’t spill past the frame:

panel.button("A label that is too long to fit", {
width: 120,
truncate: "ellipsis", // → "A label that is…"
});

Without truncate, a label wider than the button wraps to multiple lines; an auto-height button grows to fit, but a button with a fixed height can’t — so either give it room to grow or truncate the label.

UIButton is itself a flex container — .addElement() accepts any UIElement (text, image, nested panels) for icon + label compositions:

import { UIImage } from "@yagejs/ui";
const saveBtn = panel.button("Save", { onClick: () => {} });
saveBtn.addElement(new UIImage({ texture: iconTex, width: 16, height: 16 }));

Use a texture background for scalable button artwork:

panel.button("Play", {
width: 180,
height: 48,
background: {
texture: buttonTexture,
mode: "nine-slice",
nineSlice: { left: 12, top: 12, right: 12, bottom: 12 },
},
onClick: () => { /* ... */ },
});

UIPanel provides builder methods for .text(), .button(), and .panel(). For other elements, create them directly and add via addElement:

import { UIImage, UIProgressBar, UICheckbox } from "@yagejs/ui";
const img = new UIImage({
texture: iconTexture,
width: 32,
height: 32,
tint: 0xffffff,
});
panel.addElement(img);
const bar = new UIProgressBar({
width: 200,
height: 20,
value: 0.75, // 0–1
trackBackground: { color: 0x333333 },
fillBackground: { color: 0x44cc44 },
});
panel.addElement(bar);
// Update value
bar.update({ value: 0.5 });
const cb = new UICheckbox({
label: "Fullscreen",
checked: false,
size: 24,
boxColor: 0x666666,
checkColor: 0x44cc44,
onChange: (checked) => {
console.log("fullscreen:", checked);
},
});
panel.addElement(cb);

Build complex layouts with nested panels:

const menu = entity.get(UIPanel);
// Header row
const header = menu.panel({ direction: "row", gap: 12, alignItems: "center" });
header.text("Settings", { fontSize: 28, fill: 0xffffff });
// Content column
const content = menu.panel({ direction: "column", gap: 8, padding: 12 });
content.text("Volume", { fontSize: 16, fill: 0xaaaaaa });
// Button row
const buttons = menu.panel({ direction: "row", gap: 12 });
buttons.button("Save", { width: 100, height: 40, onClick: () => { /* ... */ } });
buttons.button("Cancel", { width: 100, height: 40, onClick: () => { /* ... */ } });

Toggle panel visibility at runtime:

const panel = entity.get(UIPanel);
panel.visible = false; // hide
panel.visible = true; // show

Child elements also support visibility:

label.visible = false;
btn.visible = true;

Every element accepts position, left, top, right, bottom via the shared LayoutProps. Use them to lift an element out of its parent’s flex flow and pin it against the parent’s content box — handy for overlays, badges, and HUD markers.

// Pin a notification badge to the top-right of its parent.
const badge = panel.panel({
position: "absolute",
top: 8,
right: 8,
padding: 4,
background: { color: 0xff3344, radius: 8 },
});
badge.text("3", { fontSize: 12, fill: 0xffffff });

The parent must be position: "relative" (the default) for absolute children to resolve against it. position: "absolute" children do not contribute to the parent’s main-axis advance, so siblings keep their original layout.

PropertyTypeDescription
position"relative" | "absolute"Default "relative".
left / top / right / bottomnumberPixel offset from the named edge of the parent’s content box.

Backgrounds can be a solid color or a texture:

// Solid color with rounded corners
{ color: 0x222222, alpha: 0.9, radius: 8 }
// Texture (nine-slice for scaling)
{
texture: panelTexture,
mode: "nine-slice",
nineSlice: { left: 16, top: 16, right: 16, bottom: 16 },
}