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).
UIPlugin Setup
Section titled “UIPlugin Setup”import { UIPlugin } from "@yagejs/ui";
engine.use(new UIPlugin());The plugin depends on @yagejs/renderer.
UIPanel
Section titled “UIPanel”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:
| Property | Type | Default | Description |
|---|---|---|---|
anchor | Anchor | — | Screen-space position |
offset | { x, y } | — | Pixel offset from anchor |
direction | "row" | "column" | "column" | Flex direction |
gap | number | — | Space between children |
padding | number | Padding | — | Inner padding |
alignItems | string | — | Cross-axis alignment |
justifyContent | string | — | Main-axis alignment |
overflow | "visible" | "hidden" | "visible" | Overflow behavior |
background | BackgroundOptions | — | Color or texture background |
layer | string | — | Render layer name |
visible | boolean | true | Initial visibility |
Flexbox defaults & overflow
Section titled “Flexbox defaults & overflow”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: 1on a child lets it give space back when the line is too small; aTextthen re-wraps inside its slot. An explicit value always wins. -
flex: <number>is shorthand forflexGrow: <n>+flexShrink: 1+flexBasis: 0(the CSSflex: <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 a0basis rather than its content width, it won’t shove its siblings, and its text wraps cleanly. Preferflex: 1toflexGrow: 1here:flexGrow: 1on its own keepsflexBasis: 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 & wrapscol.text("a long label that wraps within the column");row.button("Buy", { width: 68, onClick: () => {} }); // fixedNote 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.warnpointing 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.
Anchoring
Section titled “Anchoring”The Anchor enum positions panels relative to the screen:
import { Anchor } from "@yagejs/ui";
Anchor.TopLeft Anchor.TopCenter Anchor.TopRightAnchor.CenterLeft Anchor.Center Anchor.CenterRightAnchor.BottomLeft Anchor.BottomCenter Anchor.BottomRightAdd an offset to fine-tune position:
new UIPanel({ anchor: Anchor.TopRight, offset: { x: -16, y: 16 },})Layer space and positioning
Section titled “Layer space and positioning”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).
positioning: "anchor" (default)
Section titled “positioning: "anchor" (default)”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.
positioning: "transform"
Section titled “positioning: "transform"”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 withScreenFollowfrom@yagejs/renderer.ScreenFollowwritescamera.worldToScreen(target) + offsetto 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.
Entity-anchored UI with ScreenFollow
Section titled “Entity-anchored UI with ScreenFollow”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.
Tooltips (attachTooltip)
Section titled “Tooltips (attachTooltip)”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 laterlabel.setText("Score: 100");label.setStyle({ fill: 0x00ff00 });Pixel-art text & the resolution gotcha
Section titled “Pixel-art text & the resolution gotcha”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.
Animated / per-glyph text (UISplitText)
Section titled “Animated / per-glyph text (UISplitText)”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.
Buttons
Section titled “Buttons”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/enablebtn.setDisabled(true);
// Change labelbtn.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 }));Nine-Slice Buttons
Section titled “Nine-Slice Buttons”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: () => { /* ... */ },});Additional Elements
Section titled “Additional Elements”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";Images
Section titled “Images”const img = new UIImage({ texture: iconTexture, width: 32, height: 32, tint: 0xffffff,});panel.addElement(img);Progress Bars
Section titled “Progress Bars”const bar = new UIProgressBar({ width: 200, height: 20, value: 0.75, // 0–1 trackBackground: { color: 0x333333 }, fillBackground: { color: 0x44cc44 },});panel.addElement(bar);
// Update valuebar.update({ value: 0.5 });Checkboxes
Section titled “Checkboxes”const cb = new UICheckbox({ label: "Fullscreen", checked: false, size: 24, boxColor: 0x666666, checkColor: 0x44cc44, onChange: (checked) => { console.log("fullscreen:", checked); },});panel.addElement(cb);Nested Panels
Section titled “Nested Panels”Build complex layouts with nested panels:
const menu = entity.get(UIPanel);
// Header rowconst header = menu.panel({ direction: "row", gap: 12, alignItems: "center" });header.text("Settings", { fontSize: 28, fill: 0xffffff });
// Content columnconst content = menu.panel({ direction: "column", gap: 8, padding: 12 });content.text("Volume", { fontSize: 16, fill: 0xaaaaaa });
// Button rowconst buttons = menu.panel({ direction: "row", gap: 12 });buttons.button("Save", { width: 100, height: 40, onClick: () => { /* ... */ } });buttons.button("Cancel", { width: 100, height: 40, onClick: () => { /* ... */ } });Visibility
Section titled “Visibility”Toggle panel visibility at runtime:
const panel = entity.get(UIPanel);panel.visible = false; // hidepanel.visible = true; // showChild elements also support visibility:
label.visible = false;btn.visible = true;Absolute Positioning
Section titled “Absolute Positioning”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.
| Property | Type | Description |
|---|---|---|
position | "relative" | "absolute" | Default "relative". |
left / top / right / bottom | number | Pixel offset from the named edge of the parent’s content box. |
Background Options
Section titled “Background Options”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 },}