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.
Prerequisites
Section titled “Prerequisites”Install both @yagejs/ui and @yagejs/ui-react, plus React:
npm install @yagejs/ui @yagejs/ui-react reactBoth 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
Section titled “UIRoot”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.
Entity-anchored React UI
Section titled “Entity-anchored React UI”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).
JSX Components
Section titled “JSX Components”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> );}Buttons with rich content
Section titled “Buttons with rich content”<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>Available Components
Section titled “Available Components”| Component | Description |
|---|---|
<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 |
Absolute positioning
Section titled “Absolute positioning”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> — Z-axis overlay primitive
Section titled “<ZStack> — Z-axis overlay primitive”<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>Hover events
Section titled “Hover events”<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 existingonClick, so reach for these when enter and leave need separate handlers.onHover(hovering)— the convenience form: called withtrueon enter andfalseon 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> — hover-driven floating label
Section titled “<Tooltip> — hover-driven floating label”<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>| Prop | Description |
|---|---|
content | Bubble body — string/number auto-wraps in <Text>; nodes for rich content |
placement | side or side-align: "top" (default, center-aligned), "bottom-start", "right-end", … |
offset | Gap in px between trigger and bubble (default 6) |
maxWidth | Cap bubble width (px) — long content wraps instead of running off-screen |
bg / padding / textStyle | Bubble styling (headless — omit for an unstyled bubble) |
opened | Force visibility, bypassing hover (controlled / debug) |
disabled | Render 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.
Headless: useFloating
Section titled “Headless: useFloating”<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.
PixiUI Components
Section titled “PixiUI Components”Wrappers for advanced @pixi/ui widgets:
import { PixiFancyButton, PixiSlider, PixiInput, PixiSelect, PixiRadioGroup,} from "@yagejs/ui-react";Scrolling lists with <ScrollView>
Section titled “Scrolling lists with <ScrollView>”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 scrollbar — true (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.
useEngine / useScene
Section titled “useEngine / useScene”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
Section titled “useStore”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 changefunction 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 → numberuseStore(map); // ReactiveMap<K, V> → Array<[K, V]>useStore(set); // ReactiveSet<K> → K[]useStore(list); // ReactiveList<T> → T[]useStore(value); // ReactiveValue<T> → TuseStore(compound); // ReactiveStore<L> → encoded snapshotCompounds 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" changesconst moonleafQty = useStore(game.inventory, (m) => m.get("moonleaf") ?? 0);
// Custom equality checkconst pos = useStore( store, (src) => src.get().position, (a, b) => a.x === b.x && a.y === b.y,);useQuery
Section titled “useQuery”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.
useSceneSelector
Section titled “useSceneSelector”Read arbitrary scene state:
import { useSceneSelector } from "@yagejs/ui-react";
function EntityCounter() { const count = useSceneSelector((scene) => scene.getEntities().length); return <Text>Entities: {count}</Text>;}When to Use React vs Imperative
Section titled “When to Use React vs Imperative”Imperative (@yagejs/ui) | React (@yagejs/ui-react) | |
|---|---|---|
| Best for | Simple HUDs, static menus | Complex interactive menus, forms |
| State | Manual .setText() calls | Declarative with useState / useStore |
| Layout | Builder API | JSX composition |
| Bundle size | Smaller (no React) | Adds React dependency |
| Learning curve | Lower if no React experience | Lower 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.