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.

UISurface is the root UI component. Add it to an entity to create a UI tree. Its root property is the tree’s root UIPanel element — the builder methods below forward to it, and APIs that take a UIElement (like attachTooltip) accept surface.root.

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

Panel options:

PropertyTypeDefaultDescription
anchorAnchor—Screen-space position
offset{ x, y }—Pixel offset from anchor
direction"row" | "column""column"Flex direction
gapnumber—Space between children
paddingnumber | Padding—Inner padding
alignItemsstring—Cross-axis alignment
justifyContentstring—Main-axis alignment
overflow"visible" | "hidden""visible"Overflow behavior
backgroundBackgroundOptions—Color or texture background
layerstring—Render 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.

import { UISurface } from "@yagejs/ui";
// Fixed icon · growing + wrapping text column · fixed button — the common row.
const row = entity.get(UISurface).panel({ direction: "row", width: 240 });
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 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).

The warning says which child overflowed. It gives the name of the entity that holds the UISurface, the child’s position in its parent’s child list (counted from 0), and the child’s element class. For an element that shows text, it adds up to the first 30 characters of that text. The message starts like this:

UI layout [entity "Hud"]: child #2 (UIText "Score: 12500") overflows its container by 12.3px past the right edge.

A tree that is not mounted on a UISurface prints the same message without the entity name. Overflow of 2 px or less is not reported, because Yoga’s pixel rounding alone can produce that much. A child that fits again and later overflows again warns once more.

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:

import { Anchor, UISurface } from "@yagejs/ui";
new UISurface({
anchor: Anchor.TopRight,
offset: { x: -16, y: 16 },
})

To move a mounted tree later, call setOffset(x, y) on the surface. It changes the same offset the offset option sets, so calling it every frame animates a panel sliding in or out. The offset getter reads the current value back.

import { ProcessComponent, Tween, easeOutQuad } from "@yagejs/core";
import { UISurface } from "@yagejs/ui";
const menu = entity.get(UISurface);
const pc = entity.add(new ProcessComponent());
// Slide the menu down from 120 px above its anchor, over 0.3 seconds.
pc.run(Tween.custom((y) => menu.setOffset(0, y), -120, 0, 0.3, easeOutQuad));
menu.offset; // { x: 0, y: 0 } once the tween has finished

setOffset and the offset option both throw when a coordinate is not a finite number, such as NaN or Infinity.

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 { Component, Entity, Transform, Vec2 } from "@yagejs/core";
import { GraphicsComponent, ScreenFollow } from "@yagejs/renderer";
import type { CameraEntity } from "@yagejs/renderer";
import { Anchor, UISurface } from "@yagejs/ui";
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 UISurface({
positioning: "transform",
anchor: Anchor.BottomCenter,
padding: 4,
background: { color: 0x000000, alpha: 0.6, radius: 4 },
}));
panel.text(params.label, { fontSize: 11, fill: 0xffffff });
}
}
// The health component and body the enemy adds above.
class Health extends Component {
constructor(readonly options: { max: number }) {
super();
}
}
class EnemyBody extends Entity {
setup(params: { color: number }) {
this.add(new Transform());
this.add(new GraphicsComponent().draw((g) => {
g.circle(0, 0, 16).fill(params.color);
}));
}
}

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, UIPanel, UISurface, UIText } from "@yagejs/ui";
// `entity` is an EnemyNameplate from the example above.
const panel = entity.get(UISurface);
// `anchor` is any UIElement — for an entity-mounted UISurface (here the
// namecard) pass its root panel. It's read only for positioning;
// attachTooltip never wires hover onto it.
const tip = attachTooltip(panel.root, entity.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 UIPanel({
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 UISurface
// use setPointerHandlers; on a child element use its `update({ onHover })`.
panel.setPointerHandlers({ onHover: tip.setActive });
// You can release the slot early. Destroying the anchor also releases it.
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 is any UIElement; for an entity-mounted UISurface pass surface.root. You decide what shows the tooltip: set onHover — on a surface via surface.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. Destroying the anchor frees the slot automatically. You can call the returned dispose() earlier when the tooltip has a shorter lifetime. setActive is a safe no-op after disposal, so a lingering hover handler cannot touch a freed tooltip.

For fully custom popovers / menus, drop to the primitives: acquire a slot with scene.use(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.

The overlay reuses a floating element’s layout while its trigger, config, and viewport stay the same. If you change imperative content without calling setLayout again, call handle.invalidateLayout() so the next overlay update measures and positions it again. React floating content does this automatically through its normal render update.

Add text to a panel with the builder API:

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

A label only shows a value. The score itself lives in a component on a host entity, which calls setText when the score changes. See State Management.

Without truncate, a text wraps to the width it is given, either by its parent (see Flexbox defaults & overflow) or by its own width. To keep a label on one line, set truncate:

  • "clip" cuts the text after the last character that fits.
  • "ellipsis" cuts the text so that it fits with "…" appended.

Both modes leave a text that already fits unchanged. truncateWith sets the string "ellipsis" appends. The default "…" is a single character (U+2026) that not every pixel font includes, so pass "..." for a font without it. A button passes truncate and truncateWith on to its label.

// The third argument carries the rest of the UIText props.
panel.text(
"Sword of the Northern Kings",
{ fontFamily: "PressStart", fontSize: 8 },
{ width: 120, truncate: "ellipsis", truncateWith: "..." },
);

When not even one character fits, "clip" shows nothing and "ellipsis" shows only its truncateWith string. That includes a slot with no width at all. A label that shows only … is in a slot too narrow for its first character plus the ….

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 });

The .text() builder takes both props in its third argument:

panel.text("SCORE", { fontFamily: "PressStart", fontSize: 16 }, { bitmap: true });

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 0.05s; run() returns a cancel handle.
split.run(Tween.stagger(split.chars, (c) => Tween.custom((v) => (c.alpha = v), 0, 1, 0.3), 0.05));
};
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 usually live in a scene’s onEnter or in a component, where this can resolve engine services such as SceneManagerKey:

import { Scene, SceneManagerKey } from "@yagejs/core";
class GameScene extends Scene {
readonly name = "game";
}
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: () => {
void this.use(SceneManagerKey).push(new GameScene());
},
});
// Disable/enable
btn.setDisabled(true);
// Change label
btn.setText("Loading...");

Buttons support three background states: default, hover, and press. Give only background and the other two are derived from it — 1.25x brightness for hover, 0.75x for press — so a recoloured or textured button keeps its look through all three. A texture background varies its tint rather than a colour, which leaves hover untouched at the default white tint and darkens press. Channels clamp at 255, so a colour already near full brightness brightens less than the factor asks for. hoverBackground and pressBackground still win where you pass them.

A button with no background at all gets { color: 0x444444, alpha: 1, radius: 4 }. To ask for no visible background, ask for a transparent one: background: { color: 0x000000, alpha: 0 }. Passing update({ background: undefined }) resets to the grey default instead, which is what a present-but-undefined key means on every element in this package.

Omitting width and/or height lets Yoga shrink the button to fit its content, with 12 px horizontal and 6 px vertical 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); a button with both axes pinned gets no default padding. Pass padding to replace the default at any size.

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.

For a font without the … character, pass truncateWith: "..." beside truncate (see Wrapping and truncation).

UIButton is itself a flex container — .addElement() accepts any UIElement (text, image, nested panels) for icon + label compositions. It stacks its children in a column and centres them on both axes, so an icon beside a label needs direction: "row". gap, padding, alignItems and justifyContent work the same way they do on a panel:

import { UIImage } from "@yagejs/ui";
const saveBtn = panel.button("Save", {
direction: "row",
gap: 6,
onClick: () => {},
});
saveBtn.addElement(new UIImage({ texture: "save-icon", width: 16, height: 16 }));

Use a texture background for scalable button artwork:

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

A nine-slice button needs at least left + right of width and top + bottom of height, 24 × 24 px in this example, or its corners overlap. See Nine-slice insets.

UISurface, UIPanel and UIScrollView all provide the same four builder methods: .text(), .button(), .panel() and .scrollView(). On a scroll view they add to its content, so a row goes in the same way at any depth. .text() takes a third argument carrying the rest of UITextProps — bitmap, resolution, truncate, layout props.

For other elements, create them directly and add via addElement:

import { UIImage, UIProgressBar, UICheckbox } from "@yagejs/ui";
const img = new UIImage({
texture: "save-icon",
width: 32,
height: 32,
tint: 0xffffff,
});
panel.addElement(img);

UIImage, UINineSlice, and texture backgrounds accept TextureInput: a registered asset key, a texture handle, or a raw renderer texture. img.sprite is the picture itself.

Set one of width / height and the other follows the texture’s aspect ratio, so a fixed-height card keeps the picture’s proportions. Set both and the texture stretches to that box:

// 58 px tall, as wide as the picture needs.
panel.addElement(new UIImage({ texture: "card-art", height: 58 }));
// Exactly 180 x 58, whatever shape the texture has.
panel.addElement(new UIImage({ texture: "banner", width: 180, height: 58 }));

flexGrow, flex and flexBasis size the main axis too, so an image with one of those set stretches to its box as if both dimensions were given.

An image with neither dimension set measures at the texture’s own pixel size and behaves like any other flex child, so a column’s default alignItems: "stretch" stretches it across the cross axis. Sizing one axis keeps the proportions, and so does alignSelf: "flex-start".

The derived axis follows the texture rather than the room left in the parent, so a wide picture can overflow. maxWidth and maxHeight shrink both axes together: a 100 x 50 texture at height: 50, maxWidth: 40 renders 40 x 20.

import { UIProgressBar } from "@yagejs/ui";
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 });
// Read it back
bar.value; // 0.5

The bar clamps a value to 0–1, so value reads back 1 after bar.update({ value: 1.2 }).

import { UICheckbox } from "@yagejs/ui";
const cb = new UICheckbox({
label: "Fullscreen",
checked: false,
size: 24,
boxColor: 0x666666,
checkColor: 0x44cc44,
onChange: (checked) => {
console.log("fullscreen:", checked);
},
});
panel.addElement(cb);

Set focus on a UISurface or a UIPanel and its rows answer to the keyboard and the gamepad. The direction actions move a highlight between the rows, confirm runs the highlighted one, and cancel calls your onCancel. Each direction goes to the nearest row that way, measured on the laid-out boxes, so the order a player moves through is the order on screen. Rows that are hidden or disabled are passed over.

import { Anchor, UISurface } from "@yagejs/ui";
// Your game's own actions.
declare function resume(): void;
declare function openOptions(): void;
declare function quit(): void;
declare function playCursorCue(): void;
const menu = entity.add(new UISurface({
anchor: Anchor.Center,
gap: 8,
padding: 16,
background: { color: 0x101018, alpha: 0.9, radius: 8 },
focus: {
onCancel: () => resume(),
onFocusMove: () => playCursorCue(),
},
}));
menu.button("Resume", { width: 220, onClick: () => resume() });
menu.button("Options", { width: 220, onClick: () => openOptions() });
menu.button("Quit", { width: 220, onClick: () => quit() });

focus: true takes every default: wrapping at the ends, a press moving the focus, owning the pointer while the scope reads input, the shared action names, and repeats while a direction is held. UIButton, UICheckbox and the interactive @pixi/ui wrappers take focus by themselves; any other element joins with focusable: true.

The default names are move-up, move-down, move-left, move-right, interact and cancel. Bind them in the action map, or rename the roles that differ in yours:

import { UISurface } from "@yagejs/ui";
new UISurface({
focus: { input: { confirm: ["interact", "attack"], cancel: "menu-back" } },
});

Each role takes one name or a list. Holding a direction repeats it after a short delay — see Hold-to-repeat — and a gamepad stick repeats the same way with no extra code, because a stick push arrives as an ordinary key edge.

Focus draws nothing until you ask for it. Nothing is the default because a menu’s focused row is a look you design: most games put a marker beside it and move the marker from onFocusChange.

const line = menu.panel({ direction: "row", gap: 6, alignItems: "center" });
const marker = line.text("", { fontSize: 12 }, { width: 10 });
line.button("Continue", {
flex: 1,
onFocusChange: (focused) => marker.setText(focused ? ">" : ""),
onClick: () => resume(),
});

onFocusChange fires true when focus arrives and false when it leaves, including when the menu stops reading input, so a marker you painted goes out with the menu.

For the package’s own outline, ask for one with focusStyle: once on the plugin for every focused row, or on one element, overriding the plugin’s field by field.

import { UIPlugin } from "@yagejs/ui";
engine.use(new UIPlugin({ focusStyle: { color: 0x7dd3fc, width: 2 } }));
menu.button("Quit", { width: 220, focusStyle: { width: 3 } });
menu.button("Continue", { width: 220, focusStyle: null }); // no outline here

focusStyle: null on one element drops the UI-wide outline for that element alone, which is how one pane carries a marker of its own while the rest of the menu is outlined.

color, width, radius and inset each fall back on their own: the element’s value, then the plugin’s, then the default. Leave color out and it follows the fill of your defaultTextStyle, so a light theme gets an outline the player can see. Leave radius out and it follows the element’s own background radius, so a rounded button and the @pixi/ui widget beside it are rounded the same while focused. The outline is drawn inside the box and kept out of measurement, so focus changes no layout.

Hover and press keep their fills, so a row the pointer is on and the focus is on shows its hover tint with the outline on top, and the player reads both at once.

To fill the focused row as well, pass focusBackground on a UIButton or a focusable UIPanel. It is an opt-in: leave it out and a focused row keeps its resting background.

Hovered and focused are separate states, the way they are on a web page. Pressing a row focuses it. Passing the pointer over a row changes which row looks hovered and nothing else, so a player walking a list with the arrow keys keeps their row when the mouse drifts across it. Taking the pointer away from a row it focused leaves the focus there, so the keyboard picks up where the mouse left off.

pointerFocus on the scope decides which pointer input moves the focus:

new UISurface({ focus: { pointerFocus: "hover" } });
menu.focusScope?.setOptions({ pointerFocus: "none" }); // applies at once
  • "press", the default: a press focuses the row, a hover does not.
  • "hover": a hover focuses the row too — the console-style menu with one lit row that follows the mouse. A press still focuses, so a touch press with no hover before it lands.
  • "none": the pointer never moves the focus, and the keyboard and gamepad keep the row whatever the mouse does.

Each scope answers for its own setting, so a submenu can follow the pointer while the menu behind it does not. Whatever the setting, a row under the pointer still shows its hover tint, still paints pressed and still runs its action on a click.

Holding confirm on the focused row paints it pressed — the look the pointer gives it — and letting go runs the row’s action once. Let go somewhere else and nothing happens: move the focus, hide or disable the row, or close the menu while confirm is held, and the press disappears with no action. That is the rule the mouse already follows, where dragging off a button before releasing cancels the click. scope.activate() called from your own code runs at once and paints no press, because there is no hold to show.

A row looks pressed while any device holds it, and each device lets go of its own hold only. A player resting the mouse on a row and pressing confirm on the gamepad sees one pressed row that stays pressed until both are released, and moving the mouse off the row leaves the confirm press showing. Each completed press runs the row’s action once, so that player runs it twice — the same way a web page fires a click for a mouse release and for an Enter key on the same button.

A disabled button is skipped by navigation, and confirm refuses it the same way a click does — activate(), which the confirm action calls, runs the same disabled check the pointer path runs.

A row that changes a value rather than opening something takes onAdjust: left and right step the value and keep the focus, while up and down still move to the next row.

declare function upload(): void;
let volume = 60;
const row = menu.panel({
direction: "row",
gap: 12,
width: 220,
padding: 6,
background: { color: 0x222230, radius: 4 },
focusable: true,
focusId: "volume",
focusNeighbors: { down: "saves-first" },
onAdjust: (direction) => {
volume = Math.max(0, Math.min(100, volume + direction * 5));
readout.setText(`Volume ${volume}`);
},
});
const readout = row.text(`Volume ${volume}`);
menu.button("Upload save", {
width: 220,
disabled: true,
onClick: () => upload(),
});

focusId names a row so another row can point at it, and focusNeighbors says where one direction goes in place of the nearest row that way — the volume row above sends a down press to the row named saves-first, wherever the layout puts it. An id that matches nothing at the moment is ignored and the nearest row wins, so a row hidden by a filter does not strand its neighbour. Use null in place of an id to stop movement in that direction altogether.

Focus entering a row inside a UIScrollView scrolls the list to it, keeping scrollPadding pixels clear of the edge (8 by default). Your code reads no viewport size and no offset:

// Your game's save slots and loader.
declare const slots: { id: string; label: string; empty: boolean }[];
declare function load(id: string): void;
const list = menu.scrollView({ width: 220, height: 140, gap: 4 });
slots.forEach((slot, index) => {
list.button(slot.label, {
width: "100%",
disabled: slot.empty,
...(index === 0 ? { focusId: "saves-first" } : {}),
onClick: () => load(slot.id),
});
});

Moving up into the list from a button below it lands on the nearest row that is actually on screen, not on a row scrolled out of sight. Once the focus is inside the list, the next row below the fold is reachable and the list follows it.

The list follows the focused row again when a layout pass moves that row inside the content — rows arriving above it — or resizes the viewport around it. Wheeling or dragging the list changes neither, so the offset the player scrolls to is kept until focus moves.

For an explicit scroll, list.scrollIntoView(element, { align, padding }) does the same thing from your own code, and list.viewportWidth / list.viewportHeight report the clipped box in pixels.

A panel carrying its own focus is a scope of its own. While it is visible it takes the keys and the pointer, and the menu around it stops navigating — the dialog’s rows never appear in the menu’s list, and the menu’s rows never appear in the dialog’s. Hide the dialog and the menu picks up on the row it had.

declare function deleteSave(): void;
const confirmDelete = menu.panel({
gap: 8,
padding: 12,
visible: false,
background: { color: 0x181822, radius: 6 },
focus: { onCancel: () => (confirmDelete.visible = false) },
});
confirmDelete.text("Delete this save?");
confirmDelete.button("Delete", { onClick: () => deleteSave() });
confirmDelete.button("Keep", { onClick: () => (confirmDelete.visible = false) });

The dialog cannot be clicked through. While it holds the keys, everything drawn under it stops answering the pointer: a menu row behind it lights up on no hover, paints no press and runs no click, and a press on the blocked area is claimed for the UI rather than reaching your action map. The dialog’s own rows behave exactly as they always do, and a scope nested inside the dialog blocks the dialog too. Hide the dialog and the pointer comes straight back.

For a panel that wants the keys while the world behind it stays clickable — an inventory beside a live battlefield, a HUD panel a player navigates with the d-pad — turn it off:

import { UISurface } from "@yagejs/ui";
declare function closeInventory(): void;
const hud = entity.get(UISurface);
const inventory = hud.panel({
focus: { onCancel: () => closeInventory(), modal: false },
});
inventory.focusScope?.setOptions({ modal: false }); // and back at runtime

Two things to expect. A row already hovered when the dialog appears keeps its hover look until the player next moves the mouse, because the hit test runs on a pointer event and on nothing else. And the dialog blocks downwards from where it is drawn, so anything drawn above it stays interactive — a floating tooltip, a PixiSelect list, or a surface added after the menu’s.

A container that clips what it draws — a UIScrollView, or any other masked container — clips the block along with everything else inside it. Open a dialog inside a scroll view and it covers that view’s own rows, while every point outside the view stays clickable. A dialog that has to cover the screen goes in a surface of its own, outside the clip.

Nesting has no depth limit. One scope reads input at a time, and it sits in the topmost scene on the stack that has a scope shown, counting a scene you have paused. Within that scene, a scope with another shown scope inside it does not read input, which is why the dialog above reads it rather than the menu around it. Where neither of two shown scopes sits inside the other, the one shown most recently reads the input. Hiding a surface, or disabling its component or entity, hands the input back to the next scope that is still shown.

A scope consumes nothing it reads. The confirm press that activates a menu row is still visible that frame to gameplay code polling the same action, and to the dialogue and interaction addons, which poll interact as well. Separate them with action groups, on the InputManager you resolved from the engine context:

import { InputManagerKey } from "@yagejs/input";
const input = context.resolve(InputManagerKey);
input.setActiveGroups(["menu"]); // while the menu is up
input.setActiveGroups(["gameplay", "movement"]); // when it closes

surface.focusScope and panel.focusScope give you the live scope. Every method works whether or not a device is attached, which is what a cutscene, a radial menu or a unit test uses:

import type { UIButton } from "@yagejs/ui";
declare const firstSaveRow: UIButton; // the first row of the save list
const scope = menu.focusScope;
scope?.move("down"); // true when the press was used: a move, an adjust, an open list
scope?.focus(firstSaveRow); // false when that row is hidden or disabled
scope?.activate();
scope?.cancel();
scope?.focused; // the element the scope remembers, or null
scope?.hasInput; // whether this scope is the one reading input

Pass focus: { input: null } for a scope that reads no device at all and waits for those calls. Pointer focus, the scroll follow and pointer ownership keep working there — such a scope still holds the keys, so add modal: false where the world behind it must stay clickable. With @yagejs/input absent from the project every scope behaves that way, and a development build says so once.

Some elements answer the player on their own while they are focused, and take the scope’s input for as long as they do: a PixiInput holding the caret, and a PixiSelect showing its list. The scope hands that element every press it reads instead of navigating, so typing a name walks no menu and an arrow key over an open list moves the row the list will commit rather than the row behind it.

In a text field, confirm commits what was typed and leaves the field, and cancel restores the value the field had when editing began. Enter and Escape typed into the field are those same two ends, so a player who renames a save and presses Escape gets the old name back either way. The keys the player types are text entry: they do not reach the action map, so a letter bound to a gameplay action stays quiet until the player leaves the field, and so does the key they leave it with.

In an open dropdown list, up and down move the row a confirm press commits and the list scrolls to follow, stopping at either end. Confirm commits that row — the value changes, your onSelect fires and the list closes — which is what clicking the row does. Cancel closes the list on the value the select already had, and the menu around it stays open. Left and right do nothing at all while the list shows. The open list holds every direction the scope reads and acts on up and down alone, so a row’s own onAdjust runs only once the list is closed.

Either way, the scope taking its input back ends the element cleanly: focus moving off, the menu hiding, the widget being destroyed. A field keeps what was typed, and a list closes on the value it had.

An element that takes the keys also takes focus. A field the player clicks in a pointerFocus: "none" scope becomes the focused row, so the row the scope reports is always the one the keys go to.

isCapturingInput(element) answers whether that element is holding the keys. It answers per element, so a field edited in one surface says nothing about a field in another:

import { isCapturingInput } from "@yagejs/ui";
import type { PixiInput, UIText } from "@yagejs/ui";
declare const nameField: PixiInput;
declare const movementHint: UIText;
// Hide the movement prompt while the player is naming a save or picking a mode.
movementHint.visible = !isCapturingInput(nameField);

Build complex layouts with nested panels:

import { UISurface } from "@yagejs/ui";
const menu = entity.get(UISurface);
// 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:

import { UISurface } from "@yagejs/ui";
const panel = entity.get(UISurface);
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.

Every element can grow, shrink and turn where layout put it, which is what a hover pop, a card flip or a dialog that springs open needs. Four props do it:

PropertyTypeDefaultDescription
transformOriginnumber | { x, y }0The point scale and rotation turn about, as fractions of the element’s size. 0.5 is the centre.
scalenumber | { x, y }1Size multiplier about the origin. 0 and negative values are allowed.
rotationnumber0Radians about the origin.
zIndexnumber0Draw and pointer order among the element’s siblings.

Each is also a property on the element, so a tween can drive it. A card that grows about its centre while the pointer is on it, and draws over its neighbours while it does:

import {
ProcessComponent,
Tween,
easeOutBack,
easeOutQuad,
} from "@yagejs/core";
import { UISurface } from "@yagejs/ui";
const menu = entity.get(UISurface);
const row = menu.panel({ direction: "row", gap: 12 });
const pc = entity.get(ProcessComponent);
const card = row.panel({
width: 120,
height: 160,
transformOrigin: 0.5,
background: { color: 0x202030, radius: 8 },
onHover: (hovering) => {
card.zIndex = hovering ? 1 : 0;
pc.run(
Tween.custom(
(v) => (card.scale = v),
card.scale.x,
hovering ? 1.08 : 1,
0.12,
easeOutQuad,
),
);
},
});

A dialog that springs open from nothing, about its centre:

const dialog = menu.panel({
padding: 12,
transformOrigin: 0.5,
scale: 0,
background: { color: 0x181822, radius: 6 },
});
dialog.text("Delete this save?");
pc.run(Tween.custom((v) => (dialog.scale = v), 0, 1, 0.2, easeOutBack));

A few things follow from the element keeping the box layout gave it:

  • Neighbours stay put. The props change how the element is drawn, never its layout box, so a popped card overlaps its siblings instead of pushing them apart — the way a CSS transform behaves.
  • Clipping and scrolling use the layout box. In an overflow: "hidden" panel or a scroll view, a grown element is cut at the container’s edge. Put a hover pop in a panel that does not clip. A scroll view’s scroll range, and the row it scrolls to, ignore scale and rotation.
  • zIndex orders siblings only. A raised element draws over, and takes the pointer from, the siblings it overlaps. It cannot rise above an element in a neighbouring container: raise that container instead. Backgrounds stay under every child and focus outlines over them, whatever the zIndex. On a UISurface, zIndex orders the whole surface among the other contents of its layer, and a layer with its own sort function overwrites it.

Scale and rotate an element through these props rather than through element.displayObject.scale: the next time the prop is set, it replaces a value written there. Every value must be a finite number; NaN or Infinity throws, naming the element and the prop.

Backgrounds can be a solid color or a texture:

import type { BackgroundOptions } from "@yagejs/ui";
// Solid color with rounded corners
const solid: BackgroundOptions = { color: 0x222222, alpha: 0.9, radius: 8 };
// Texture (nine-slice for scaling)
const framed: BackgroundOptions = {
texture: "panel-frame",
mode: "nine-slice",
nineSlice: { left: 16, top: 16, right: 16, bottom: 16 },
};

An element reads the options when you pass them. Changing a field on that object afterwards does not change what is drawn. To change a background, pass a new object. It replaces the previous options whole: a field you leave out takes its default, not its previous value.

import { UISurface } from "@yagejs/ui";
const menu = entity.get(UISurface);
const card = menu.panel({
padding: 8,
background: { color: 0x222222, radius: 8 },
});
// Later: a new object replaces the whole background.
card.update({ background: { color: 0x333333, radius: 8 } });
// A surface's own background belongs to its root panel.
menu.root.update({ background: { color: 0x000000, alpha: 0.5 } });

The nineSlice insets come from the background options, not from the texture’s own metadata. When you swap nine-slice art, pass mode: "nine-slice" and nineSlice again with the new texture. Without them, the whole texture stretches to the box and its corners stretch with it.

A nine-slice box needs room for its insets: at least left + right wide and top + bottom tall. In a smaller box the corners overlap and there is no room for the middle row or column. Development builds log a console.warn when a nine-slice background or a UINineSlice element is laid out smaller than that. Nothing is resized for you. Give the element more room, or use art with smaller insets. The warning repeats if the element fits again and later becomes too small again.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/ui.md.