Building UI with React-ECS
Decentraland SDK7 uses a React-like JSX system for 2D UI overlays.
When to Use Which UI Approach
| Need | Approach | Component |
|---|
| Screen-space HUD, menus, buttons | React-ECS (this skill) | , , , , |
| 3D text floating in the world | TextShape + Billboard | See advanced-rendering skill |
| Open a web page | | See scene-runtime skill |
| Clickable objects in 3D space | Pointer events | See add-interactivity skill |
Use React-ECS for any 2D overlay: scoreboards, health bars, dialogs, inventories, settings menus. Use TextShape for labels above NPCs or objects in the 3D world.
Setup
Create
with your UI component and call
ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 })
from
. Call
from
in
. The SDK template already includes the required JSX settings in tsconfig.json — do NOT modify it.
DEFAULT RULE: Always Set Virtual Screen Size to 1920x1080
Whenever you generate UI code, you MUST pass { virtualWidth: 1920, virtualHeight: 1080 }
to and by default — without waiting for the user to ask. Only deviate if the user explicitly requests a different reference resolution.
Why: Without a virtual size, UI is laid out in raw screen pixels and renders inconsistently across different resolutions and aspect ratios — fonts, spacing, and absolute-positioned elements drift between displays. Setting a virtual screen size makes the engine scale the UI proportionally to a fixed reference frame, so layouts look the same on every screen. 1920x1080 is the safe default — it matches the most common displays and the assumption made by most community examples.
The options argument is optional at the API level —
is valid, and several engine test scenes omit it. Passing it is still the default rule here; only omit it if the user explicitly wants raw-pixel layout.
API (verified against
7.22.5, file
):
ts
type UiRendererOptions = { virtualWidth: number; virtualHeight: number }
setUiRenderer(ui: UiComponent, options?: UiRendererOptions): void
addUiRenderer(entity: Entity, ui: UiComponent, options?: UiRendererOptions): void
Canonical snippet (use this verbatim unless the user specifies otherwise):
tsx
import { ReactEcsRenderer } from '@dcl/sdk/react-ecs'
export function setupUi() {
ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 })
}
Core Components
UiEntity — Container element. Key props:
(width, height, positionType, position, flexDirection, justifyContent, alignItems, alignContent, alignSelf, padding, margin, display, overflow, flexWrap, flexGrow,
,
,
,
,
,
),
(color, texture, textureMode, textureSlices, uvs, avatarTexture),
(value, fontSize, color, textAlign, font). Events:
,
,
,
.
-
These four are the complete set of UI event handlers, and each is . There is no
/
, and
no arguments — no pointer coordinates, no event object — are passed to a handler. All four are hardcoded to
; you cannot bind a UI element to right-click or a key. Drag interactions are still fully possible via
PrimaryPointerInfo.screenDelta
— see "Sliders" below.
-
(number 0–1): fades the element. Set on the root to fade the whole UI;
cascades multiplicatively to children.
-
(number, incl. negative): controls stacking order among sibling elements. Higher = on top. Does not cross parent boundaries.
-
/
(
) /
: also valid on
,
,
via their
.
-
/
accept a number (px),
,
, or
.
/
/
values accept the same string forms;
also accepts a CSS shorthand string, e.g.
margin: '16px 0 8px 270px'
.
Label — Text display. Key props:
,
,
,
(e.g.
),
(
|
|
),
.
Button — Clickable button. Key props:
,
(
|
),
,
,
.
Input — Text input field. Key props:
,
,
,
,
,
.
Dropdown — Selection dropdown. Key props:
(string[]),
,
,
,
,
.
ScreenInsetArea — Wrapper that keeps children inside the device's hardware-reserved margins (notch, status bar, home indicator, rounded corners). On mobile, it positions itself absolutely using the insets the device reports. On desktop the insets are
, so it's a no-op — safe to leave in cross-platform UI. It owns its own
and
; any values you pass for those in
are ignored. All other
props (
,
,
, …) and components (
,
, …) work as usual. Wrap any mobile-sensitive HUD in it; a child sized
width: '100%', height: '100%'
fills the safe area exactly. Distinct from the
Decentraland system HUD reserved zones (joystick, chat, profile, interaction button) — those still need to be avoided manually; use both together. UI designed for desktop typically needs sizes scaled ~3× for mobile readability.
InteractableArea — Wrapper that keeps children inside the renderer-reported
interactable area — the part of the screen NOT covered by the client's own UI (minimap, chat window, platform overlays). Reads
UiCanvasInformation.interactableArea
and constrains children via absolute positioning; on the Unity desktop client the left ~25% of the screen is reserved, so children fill the remaining ~75%. Like
, it owns
/
(values you pass are ignored) and falls back to zero insets (no-op) when unavailable. Import from
; usage
<InteractableArea><MyHud /></InteractableArea>
. Distinct from
(which avoids
device hardware margins, not client UI). See
{baseDir}/references/ui-components.md
→ InteractableArea.
Adding Independent UI Renderers (addUiRenderer)
Use
ReactEcsRenderer.addUiRenderer(ownerEntity, MyWidget, { virtualWidth: 1920, virtualHeight: 1080 })
to render a UI module independently without replacing the main UI. Useful for smart items or modular scene components. Remove with
ReactEcsRenderer.removeUiRenderer(owner)
. If the owner entity is destroyed, the UI is removed automatically.
State Management
Use module-level variables for UI state — React hooks (
,
, etc.) are
NOT available. The UI renderer re-renders every frame, so state changes are reflected immediately. Export functions to update state from game logic.
Common UI Patterns
- Health bar — Nested UiEntity with width as percentage
- Image background — with and
- Screen dimensions — Read via
UiCanvasInformation.getOrNull(engine.RootEntity)
- Nine-slice textures —
textureMode: 'nine-slices'
with for scalable panels
- Texture UVs / Sprite sheets — array (8 numbers) to select texture regions
- Hover events — / on UiEntity
- Flex wrap — for grid layouts
- Scrollable containers — on a fixed-size parent to scroll through overflowing content (drag or mouse wheel). Use to clip overflow without scrolling. Use on scrollable entities to fill remaining space
- Texture tint — set alongside in to tint the image (works with and )
- Multiple stacked layers — the renderer function may return an array of elements, e.g.
setUiRenderer(() => [PanelA(), PanelB()])
; later items in the array render on top of earlier ones
- Opacity / z-index — and on (see Core Components); root fades the whole HUD
Gotchas (verified against engine test scenes)
- and are uncontrolled. / fire with the current value, but the field does not read back from the / prop you pass each frame the way React does. To programmatically clear an , briefly set to a non-empty sentinel (e.g. ) for one frame, then back to . Do not expect setting to force the displayed text every frame.
- is per-sibling-group. It orders siblings within the same parent; it does not lift an element above elements in a different branch of the tree. Use array-return ordering or tree structure for cross-branch stacking.
- multiplies down the tree. A child at inside a root at renders at 0.4 effective. Don't stack opacities unintentionally.
- deforms non-uniform art; use (with ) for panels/buttons that must scale without distorting borders, and to draw the texture at native size centered in the element.
- Texture paths are relative to the scene root (e.g. ), not to .
- No pointer coordinates in UI handlers. /// are — the reconciler discards the before calling your callback, so "where on this element did they click" is unavailable. Track movement instead of position:
PrimaryPointerInfo.screenDelta
reports per-frame mouse travel and drives drag interactions fine. See {baseDir}/references/ui-sliders.md
.
- UI elements with a handler become pointer-blocking. Adding makes the element block clicks to the 3D world behind it; elements without one let clicks through. Override either way with
uiTransform.pointerFilter: 'block' | 'none'
(default ).
Common Widgets — Build From Scratch
Build every widget from React-ECS primitives (
,
,
). There is no pre-built widget library to install.
- Prompt / dialog / confirmation? → full-screen overlay + centered panel + s. See the Modal Dialog pattern in
references/ui-components.md
.
- Health bar, progress bar, score? → nested with the inner one sized ${pct}%``. See the Health Bar patterns in
references/ui-components.md
and references/ui-patterns.md
; a score is a bound to a module-level variable.
- Flash announcement (timed, centered)? → a centered gated on a module-level flag, cleared with . See Timed Announcement in
references/ui-patterns.md
.
- Slider / drag handle / scrub bar? → drag sliders work. UI handlers get no pointer coordinates, so instead: on the track starts a drag, and a system accumulates
PrimaryPointerInfo.screenDelta.x
(divided by the UI scale factor) into the value. A full-screen overlay rendered only while dragging catches the release. Verified in-world on both the Unity and Bevy explorers. Desktop only — is always 0 on mobile, so pair the track with / stepper s. Full implementation in {baseDir}/references/ui-sliders.md
.
- Custom panel, inventory, complex layout? → React-ECS directly (see
references/ui-patterns.md
).
Troubleshooting
Work through the wiring causes in this table in order before speculating about layout-level causes (sizing,
, off-screen positioning, color-on-color) — wiring problems are the cause by a wide margin.
| Problem | Cause | Solution |
|---|
| UI not rendering / invisible / nothing on screen (most common) | is not called from in — users sometimes remove or comment out this call | Add the call inside . Always check this first. |
| UI not rendering even though is called | ReactEcsRenderer.setUiRenderer(...)
missing from itself | Add ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 })
|
| UI blank on first frames, sometimes appears later | Root component returns (or falsy) on first render with no fallback | Render a placeholder or hidden root instead of returning |
| Multiple UIs fighting / UI missing | More than one call — later calls replace earlier ones, so only the last one wins | Only call once — combine all UI into a single root component, or use with separate owner entities |
| Absolute-positioned children laid out unexpectedly | Root has no / — without a full-canvas root, some absolute-positioned children may not render | Add uiTransform={{ width: '100%', height: '100%' }}
to the root — see "Convention" section below for empirical evidence. |
| UI elements overlapping | Missing or wrong layout | Set on the parent container |
| Button clicks not registering | Missing handler | Add onMouseDown={() => { ... }}
to the Button or UiEntity |
| JSX errors at compile time | File extension is instead of | Rename the file to |
| Text not visible | Text color matches background | Set contrasting on Label or |
Convention: root must set width: '100%', height: '100%'
Set
uiTransform={{ width: '100%', height: '100%' }}
on the root
returned to
/
whenever the UI uses absolute positioning. Do this by default.
Note: this is required specifically so absolute-positioned children get a full-screen positioning context. Some engine test scenes that lay everything out with flow/
(no absolute children) use a smaller root (e.g.
or
) and render fine — but a full-canvas root is the safe default and never hurts.
Rationale (empirically verified — tested in-engine June 2026):
- Without a full-canvas root, absolute-positioned children using may fail to render entirely. In testing, a root with no explicit / caused a positioned child to disappear while a child rendered correctly. Adding
width: '100%', height: '100%'
to the root fixed the issue.
- A full-canvas root gives absolute-positioned children ( with
position: { top, left, ... }
) a known, full-screen positioning context. This matches the implicit assumption most HUD code makes.
- It avoids edge-case layout surprises with Yoga's default sizing for unspecified /.
Example scenes
Engine-team test scenes exercised against the real renderer (ground truth for the APIs above):
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,6-ui-zindex-and-opacity — (incl. negative) and on , including root-level opacity cascade; buttons cycle values.
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/70,-9-sdk7-ui-backgrounds — every texture mode (, , ), color tinting over textures, , and .
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/80,-3-ui — /// end to end, on , CSS-shorthand strings, sizing, .
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/81,-3-ui-2 — array-return of stacked panels, toggling, border props (//) on Input/Dropdown/Button, uncontrolled-input clear trick, textured (nine-slices) vs. clickable .
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/76,-10-UiCanvasInformation — reading each frame into a module variable to size UI responsively.
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,7-portable-experience-hide-ui — hiding a portable experience's UI via
featureToggles.portableExperiences: "hideUi"
in (scene-config, not React-ECS).
For full code examples and implementation patterns, see
{baseDir}/references/ui-patterns.md
. For component prop details, see
{baseDir}/references/ui-components.md
. For sliders and the limits of UI pointer input, see
{baseDir}/references/ui-sliders.md
.
Cross-references
- Platform detection: Use / from to branch UI for mobile vs. desktop. See the advanced-input skill.
- Mobile UI limitations: is unsupported on mobile. Design for touch (larger tap targets, no hover states). See the mobile considerations in the advanced-input skill.