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>
<Button width={150} height={40} 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> );}Available Components
Section titled “Available Components”| Component | Description |
|---|---|
<Panel> | Flexbox container with direction, gap, padding, bg |
<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 |
PixiUI Components
Section titled “PixiUI Components”Wrappers for advanced @pixi/ui widgets:
import { PixiFancyButton, PixiSlider, PixiInput, PixiSelect, PixiRadioGroup, PixiScrollBox,} from "@yagejs/ui-react";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”Bridge ECS state into React with reactive stores:
import { createStore } from "@yagejs/ui-react";import { useStore } from "@yagejs/ui-react";
// Create a store (typically at scene level)const gameStore = createStore({ 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, (s) => s.score); return <Text style={{ fontSize: 32 }}>{`Score: ${score}`}</Text>;}The optional selector and equality function prevent unnecessary re-renders:
// Only re-render when score changesconst score = useStore(store, (s) => s.score);
// Custom equality checkconst pos = useStore(store, (s) => s.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.