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.
UISurface
Section titled “UISurface”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:
| 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.
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 & 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
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).
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.
Anchoring
Section titled “Anchoring”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 finishedsetOffset and the offset option both throw when a coordinate is not a
finite number, such as NaN or Infinity.
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 { 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.
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, 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 laterlabel.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.
Wrapping and truncation
Section titled “Wrapping and truncation”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 ….
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 });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.
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 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
Section titled “Buttons”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/enablebtn.setDisabled(true);
// Change labelbtn.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 }));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: "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.
Additional Elements
Section titled “Additional Elements”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";Images
Section titled “Images”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.
Sizing
Section titled “Sizing”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.
Progress Bars
Section titled “Progress Bars”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 valuebar.update({ value: 0.5 });
// Read it backbar.value; // 0.5The bar clamps a value to 0–1, so value reads back 1 after
bar.update({ value: 1.2 }).
Checkboxes
Section titled “Checkboxes”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);Keyboard and gamepad focus
Section titled “Keyboard and gamepad focus”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 actions a scope reads
Section titled “The actions a scope reads”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.
What the player sees
Section titled “What the player sees”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 herefocusStyle: 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.
What the mouse does to the focus
Section titled “What the mouse does to the focus”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.
Disabled rows and stepper rows
Section titled “Disabled rows and stepper rows”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.
Scrolling lists
Section titled “Scrolling lists”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 dialog inside the menu
Section titled “A dialog inside the menu”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 runtimeTwo 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.
Keeping menu input out of gameplay
Section titled “Keeping menu input out of gameplay”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 upinput.setActiveGroups(["gameplay", "movement"]); // when it closesDriving a scope yourself
Section titled “Driving a scope yourself”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 listscope?.focus(firstSaveRow); // false when that row is hidden or disabledscope?.activate();scope?.cancel();
scope?.focused; // the element the scope remembers, or nullscope?.hasInput; // whether this scope is the one reading inputPass 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.
An element that takes the keys
Section titled “An element that takes the keys”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);Nested Panels
Section titled “Nested Panels”Build complex layouts with nested panels:
import { UISurface } from "@yagejs/ui";
const menu = entity.get(UISurface);
// 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:
import { UISurface } from "@yagejs/ui";
const panel = entity.get(UISurface);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. |
Scale, rotation and draw order
Section titled “Scale, rotation and draw order”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:
| Property | Type | Default | Description |
|---|---|---|---|
transformOrigin | number | { x, y } | 0 | The point scale and rotation turn about, as fractions of the element’s size. 0.5 is the centre. |
scale | number | { x, y } | 1 | Size multiplier about the origin. 0 and negative values are allowed. |
rotation | number | 0 | Radians about the origin. |
zIndex | number | 0 | Draw 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
transformbehaves. - 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. zIndexorders 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 thezIndex. On aUISurface,zIndexorders the whole surface among the other contents of its layer, and a layer with its ownsortfunction 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.
Background Options
Section titled “Background Options”Backgrounds can be a solid color or a texture:
import type { BackgroundOptions } from "@yagejs/ui";
// Solid color with rounded cornersconst 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 } });Nine-slice insets
Section titled “Nine-slice insets”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.