Skip to content

UI (React)

The @yagejs/ui-react package provides a React reconciler over the UI system, letting you write game UI with JSX. It includes hooks for accessing engine services and reactive stores for bridging ECS state into React.

Install both @yagejs/ui and @yagejs/ui-react, plus React:

Terminal window
npm install @yagejs/ui @yagejs/ui-react react

Both UIPlugin and UIReactPlugin must be registered before using React UI. UIReactPlugin registers the LateUpdate-phase layout pass that positions UIRoot trees after any Update-phase Transform writers (e.g. ScreenFollow):

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

UIRoot is a component that hosts a React tree on an entity:

import { UIRoot } from "@yagejs/ui-react";
import { Anchor } from "@yagejs/ui";
const ui = scene.spawn("ui");
ui.add(new Transform());
const root = new UIRoot({ anchor: Anchor.Center });
ui.add(root);
root.render(<MyMenu />);

By default the React tree renders into the auto-provisioned screen-space "ui" layer, positioned by anchor and optional offset against the viewport. Pass layer: "<name>" to mount on any layer declared on Scene.layers.

For UI that tracks a specific game entity (nameplates, health bars, damage numbers), set positioning: "transform" on the UIRoot and pair with ScreenFollow from @yagejs/renderer. ScreenFollow writes camera.worldToScreen(target) + offset to this entity’s Transform each frame (the offset is in screen pixels, applied after projection), and the UIRoot reads that Transform instead of the viewport — so the UI tracks the target while staying axis-aligned and constant-size under any camera zoom or rotation.

import { ScreenFollow } from "@yagejs/renderer";
import { UIRoot, Panel, Text } from "@yagejs/ui-react";
import { Anchor } from "@yagejs/ui";
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
}));
const root = this.add(new UIRoot({
positioning: "transform", // read Transform.worldPosition
anchor: Anchor.BottomCenter, // pivot on the rendered tree
}));
root.render(<NameplateView label={params.label} />);
}
}
function NameplateView({ label }: { label: string }) {
return (
<Panel padding={4} bg={{ color: 0x000000, alpha: 0.6, radius: 4 }}>
<Text style={{ fontSize: 11, fill: 0xffffff }}>{label}</Text>
</Panel>
);
}

UIRoot with positioning: "transform" requires a Transform on the entity and throws at add time otherwise. See the imperative UI guide for the full positioning model (the two modes, the world-space-layer variant for genuinely diegetic UI, and when to pick which).

React components mirror the imperative @yagejs/ui API:

import { Panel, Text, Button, Image, ProgressBar, Checkbox } from "@yagejs/ui-react";
function HUD() {
const [score, setScore] = useState(0);
return (
<Panel direction="column" gap={8} padding={16} bg={{ color: 0x000000, alpha: 0.7 }}>
<Text style={{ fontSize: 24, fill: 0xffffff }}>Score: {score}</Text>
{/* width/height are optional — omit them and the button shrinks to
fit its content. */}
<Button
bg={{ color: 0x4444aa }}
hoverBg={{ color: 0x5555cc }}
textStyle={{ fontSize: 16, fill: 0xffffff }}
onClick={() => setScore(s => s + 1)}
>
Add Point
</Button>
<ProgressBar
width={200}
height={16}
value={score / 10}
fillBackground={{ color: 0x44cc44 }}
trackBackground={{ color: 0x333333 }}
/>
<Checkbox
label="Mute"
checked={false}
onChange={(v) => console.log("mute:", v)}
/>
</Panel>
);
}

<Button> accepts any ReactNode as children. Pass a string for the common labeled-button case (it’s auto-wrapped in a centered <Text> styled with textStyle), or pass JSX children to compose icons and text:

<Button onClick={() => save()}>
<Image texture={saveIcon} width={16} height={16} />
<Text style={{ fontSize: 14, fill: 0xffffff }}>Save</Text>
</Button>
ComponentDescription
<Panel>Flexbox container with direction, gap, padding, bg
<ZStack>Z-axis overlay panel — fills its parent, children layer via position="absolute"
<Text>Text label with style
<Button>Clickable button with onClick, bg, hoverBg, pressBg
<Image>Texture display with texture, tint, alpha
<NineSlice>Nine-slice scalable texture
<ProgressBar>Progress bar (value 0–1)
<Checkbox>Checkbox with checked, onChange, label
<Tooltip>Hover-driven floating label wrapping a trigger

Every component accepts the shared layout props position, left, top, right, bottom. Combine them with a relative parent to overlay elements:

<Panel position="relative" width={400} height={300}>
<ProgressBar width={400} height={300} value={progress} />
<Panel position="absolute" top={8} right={8} padding={4} bg={{ color: 0x000000, alpha: 0.6 }}>
<Text style={{ fontSize: 12 }}>{Math.round(progress * 100)}%</Text>
</Panel>
</Panel>

<ZStack> is a <Panel> that defaults to width: "100%", height: "100%", and position: "relative". Drop absolute-positioned children inside to layer them at (0, 0) on the Z axis — modal backdrops, HUD layers, and badge markers. The name follows the SwiftUI convention (VStack / HStack for flow layout, ZStack for depth); for column / row stacking in YAGE use <Panel direction="column" | "row">.

<ZStack>
{/* Dimmed backdrop fills the stack */}
<Panel position="absolute" left={0} top={0} width="100%" height="100%"
bg={{ color: 0x000000, alpha: 0.6 }} />
{/* Centered modal */}
<Panel position="absolute" left={0} top={0} width="100%" height="100%"
alignItems="center" justifyContent="center">
<Panel padding={24} gap={12} bg={{ color: 0x222222, radius: 8 }}>
<Text style={{ fontSize: 20 }}>Are you sure?</Text>
<Panel direction="row" gap={8}>
<Button onClick={onConfirm}>Confirm</Button>
<Button onClick={onCancel}>Cancel</Button>
</Panel>
</Panel>
</Panel>
</ZStack>

<Panel>, <Button>, <Text>, <Image>, <NineSlice>, and <ProgressBar> accept hover callbacks. Their containers are already interactive, so this is a lightweight fan-out — there is no separate “make it hoverable” step.

Three independent props (combine them freely):

  • onPointerOver / onPointerOut — fire on pointer enter / leave. They mirror Pixi’s event names and the existing onClick, so reach for these when enter and leave need separate handlers.
  • onHover(hovering) — the convenience form: called with true on enter and false on leave. One setter drives a “while hovered” state.
function HoverableCard() {
const [active, setActive] = useState(false);
return (
<Panel
onHover={setActive}
bg={{ color: active ? 0x3355aa : 0x223044 }}
padding={12}
>
<Text>{active ? "Release to deselect" : "Hover me"}</Text>
</Panel>
);
}

Hover callbacks are suppressed while a <Button disabled> (matching its existing disabled hover-background behavior).

<Tooltip> wraps a trigger element and shows a floating bubble while the pointer is over it. It follows the Mantine convention — a single wrapper with the body in a content prop — rather than compound trigger/content subcomponents. It is headless: no default visuals — pass bg / padding / textStyle to style it.

<Tooltip
content="Save your game"
placement="top"
bg={{ color: 0x1f2430, radius: 6 }}
padding={8}
>
<Button onClick={save}>Save</Button>
</Tooltip>

content also takes arbitrary nodes for rich tooltips (item cards, stat breakdowns):

<Tooltip
placement="right"
content={
<Panel gap={2}>
<Text style={{ fontWeight: "bold" }}>Iron Sword</Text>
<Text style={{ fontSize: 12 }}>+5 ATK · Rare</Text>
</Panel>
}
>
<Image texture={swordIcon} width={32} height={32} />
</Tooltip>
PropDescription
contentBubble body — string/number auto-wraps in <Text>; nodes for rich content
placementside or side-align: "top" (default, center-aligned), "bottom-start", "right-end", …
offsetGap in px between trigger and bubble (default 6)
maxWidthCap bubble width (px) — long content wraps instead of running off-screen
bg / padding / textStyleBubble styling (headless — omit for an unstyled bubble)
openedForce visibility, bypassing hover (controlled / debug)
disabledRender the trigger only — never show the bubble

Under a <UIRoot> (the normal case) the bubble is portaled into the scene’s top-most screen-space overlay and anchored by the positioning engine. It draws above all other UI, escapes a <ScrollView>’s clip, never reflows siblings, flips to the opposite side and shifts along the cross axis to stay on-screen, caps to maxWidth (and to the space available at the resolved side), z-stacks across roots (most-recently-opened on top), and anchors correctly even for world-space / camera-transformed triggers. Rendered without a <UIRoot> overlay (a bare reconciler tree) it falls back to an in-tree absolute bubble with no collision handling.

<Tooltip> is a thin wrapper over the exported useFloating primitive — use it directly for custom popovers, dropdowns, hovercards, or context menus. useFloating({ open, placement, offset, padding, maxWidth, flip, shift }) returns { setReference, renderFloating, hasOverlay }: wire setReference to the trigger’s ref and render renderFloating(node) in your tree — it portals node into the scene overlay while open (keeping it in your React tree so context/props/lifecycle still flow) and returns null when closed. The pure computePosition() engine and Placement type are also exported for fully custom floating layers.

Wrappers for advanced @pixi/ui widgets:

import {
PixiFancyButton,
PixiSlider,
PixiInput,
PixiSelect,
PixiRadioGroup,
} from "@yagejs/ui-react";

Any list whose length you can’t bound at design time — inventories, quest logs, chat, order tickets, leaderboards — should live inside a <ScrollView>. A plain <Panel> simply clips overflow without scrolling, so once the list outgrows its container the extra entries disappear silently. <ScrollView> is a true Yoga container: its children are ordinary elements laid out by the normal flexbox pass (not handed off to a foreign widget), it adds wheel + drag scrolling, and it preserves scroll position across re-renders — fulfilling or refilling a store-driven list never jumps the scroll back to the top.

import { ScrollView, Panel, Button, Text } from "@yagejs/ui-react";
interface Order { id: string; label: string }
function OrdersPanel({
orders,
fulfill,
endDay,
}: {
orders: Order[];
fulfill: (id: string) => void;
endDay: () => void;
}) {
return (
<Panel direction="column" width={300} height={220} gap={10} padding={10}
bg={{ color: 0x000000, alpha: 0.7 }}>
<Text style={{ fontSize: 16, fill: 0x93c5fd }}>Orders</Text>
<ScrollView flexGrow={1} gap={6} bg={{ color: 0x0b1220 }}>
{orders.map((o) => (
<Panel key={o.id} direction="row" height={36} padding={{ left: 8, right: 6 }}
alignItems="center" justifyContent="space-between"
bg={{ color: 0x243042, radius: 4 }}>
<Text style={{ fontSize: 14, fill: 0xe5e7eb }}>{o.label}</Text>
<Button height={24} onClick={() => fulfill(o.id)}>Fulfill</Button>
</Panel>
))}
</ScrollView>
{/* A sibling of <ScrollView>, so it stays put while the list scrolls. */}
<Button height={36} onClick={endDay}>End Day</Button>
</Panel>
);
}

Size the viewport with the standard layout props — height for a fixed box, or flexGrow={1} to fill the space left by fixed siblings. Content overflowing the scroll axis is clipped and pannable by wheel or drag — anywhere over the box, including the gaps between cards and the scrollbar gutter (not just directly over a card). Props: direction ("vertical" default, or "horizontal"), gap, padding, bg, onScroll(offset), and scrollbartrue (default) / false, or a ScrollbarOptions object (thickness, color, alpha, radius, minThumbLength, margin). When the thumb is shown a gutter equal to its footprint is auto-reserved so content never renders under it. Keep anything that should stay fixed — a header, a footer button — as a sibling of <ScrollView>, never a child.

For imperative control, a ref to the node exposes scrollBy(), scrollTo(), scrollOffset, and maxScroll. The same element is available outside React via the panel.scrollView(opts) builder on UIPanel / PanelNode.

Access the engine context or current scene from any React component:

import { useEngine, useScene } from "@yagejs/ui-react";
function PauseButton() {
const engine = useEngine();
return (
<Button onClick={() => engine.scenes.push(new PauseScene())} width={100} height={40}>
Pause
</Button>
);
}

useStore is the single React entry for every reactive primitive — counters, records, maps, sets, lists, single-cell values, and compound stores. One overload per shape, plus a selector escape hatch. Dispatch is symbol-driven (each shape carries a [STATE_KIND] brand from @yagejs/core).

import { createRecord } from "@yagejs/core";
import { useStore } from "@yagejs/ui-react";
// Plain record (object with shallow merge)
const gameStore = createRecord({ default: () => ({ score: 0, health: 100 }) });
// Write from ECS (systems, components, event handlers)
gameStore.set({ score: gameStore.get().score + 10 });
// Read from React — auto-rerenders on change
function ScoreDisplay() {
const { score } = useStore(gameStore);
return <Text style={{ fontSize: 32 }}>{`Score: ${score}`}</Text>;
}

Each useStore(source) returns the source’s natural snapshot:

useStore(record); // ReactiveRecord<T> → Readonly<T>
useStore(counter); // ReactiveCounter → number
useStore(map); // ReactiveMap<K, V> → Array<[K, V]>
useStore(set); // ReactiveSet<K> → K[]
useStore(list); // ReactiveList<T> → T[]
useStore(value); // ReactiveValue<T> → T
useStore(compound); // ReactiveStore<L> → encoded snapshot

Compounds returned by createStore are accepted too — useStore(compound) returns the encoded snapshot of the whole tree. For finer subscription granularity read individual leaves:

const game = createStore((s) => ({
gold: s.counter({ default: 0 }),
inventory: s.map<string, number>(),
}));
function HUD() {
const gold = useStore(game.gold); // number
const entries = useStore(game.inventory); // Array<[string, number]>
return <Text>{`${gold} gold · ${entries.length} items`}</Text>;
}

For partial reads, use the selector escape hatch. The selector receives the source itself, not a snapshot — call its accessors inside:

// One map entry only — re-renders only when "moonleaf" changes
const moonleafQty = useStore(game.inventory, (m) => m.get("moonleaf") ?? 0);
// Custom equality check
const pos = useStore(
store,
(src) => src.get().position,
(a, b) => a.x === b.x && a.y === b.y,
);

Query ECS entities directly from React:

import { useQuery } from "@yagejs/ui-react";
function EnemyCount() {
const count = useQuery(
[EnemyTag],
(result) => result.size,
);
return <Text>Enemies: {count}</Text>;
}

The query re-evaluates each frame and only triggers a re-render when the selector result changes.

Read arbitrary scene state:

import { useSceneSelector } from "@yagejs/ui-react";
function EntityCounter() {
const count = useSceneSelector((scene) => scene.getEntities().length);
return <Text>Entities: {count}</Text>;
}
Imperative (@yagejs/ui)React (@yagejs/ui-react)
Best forSimple HUDs, static menusComplex interactive menus, forms
StateManual .setText() callsDeclarative with useState / useStore
LayoutBuilder APIJSX composition
Bundle sizeSmaller (no React)Adds React dependency
Learning curveLower if no React experienceLower if familiar with React

Use the imperative API for simple, mostly-static UI (score counters, health bars). Use React when your UI has complex state, conditional rendering, or many interactive elements.