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) | UiEntity, Label, Button, Input, Dropdown |
| 3D text floating in the world | TextShape + Billboard | See advanced-rendering skill |
| Open a web page | openExternalUrl |
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 src/ui.tsx with your UI component and call ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 }) from setupUi(). Call setupUi() from main() in src/index.ts. 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
The SDK uses a virtual screen to scale UI consistently across display resolutions: when a virtual size is active, all pixel values in uiTransform are relative to the virtual canvas, not the physical screen.
Whenever you generate UI code, you MUST pass { virtualWidth: 1920, virtualHeight: 1080 } to setUiRenderer and addUiRenderer by default — without waiting for the user to ask. Only deviate if the user explicitly requests a different reference resolution.
Why: pixel values in a UI are only meaningful against a reference resolution. Stating it explicitly in the code keeps the scene's layout intent visible and pins it, instead of leaving it to a per-platform default that differs between mobile and desktop. 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. On SDK 7.26.0+, omitting it does not mean "no scaling" — the SDK applies a platform default virtual screen instead:
| Case | Resulting virtual screen |
|---|---|
| No virtual size passed, non-mobile | 1920x1080 |
| No virtual size passed, mobile | 1600x720 |
A 16:9 size passed (e.g. 1920x1080), mobile |
overridden to 1600x720, logged once to console |
| A non-16:9 size passed | used as-is on every platform |
A size with any value <= 0 |
virtual screen disabled — raw canvas pixels, no scaling. Silent: this is the documented opt-out |
| Only one of the two dimensions passed | also disabled (both are required), and logged once per size — it is treated as a mistake, not an opt-out |
The mobile 16:9 override exists because phone screens are much wider than 16:9 — a 16:9 virtual canvas would letterbox the UI there.
So on 7.26.0+, { virtualWidth: 0, virtualHeight: 0 } — not omitting the options — is how you opt into raw-pixel layout. Only do that if the user explicitly asks for it. Below 7.26.0 there are no defaults: omitting the options is what disables scaling. See the version gate below.
Because the default rule above has you pass the size explicitly either way, generated code behaves identically on both sides of that boundary — which is a second reason to always pass it.
The virtual size is scene-wide, resolved as: the size on setUiRenderer wins → else the first addUiRenderer that passed one → else the platform default. Options carrying only a screenInset don't count as a passed size.
Note that setUiRenderer wins the arbitration if it mentions either dimension, even when the size is incomplete and therefore invalid. So setUiRenderer(ui, { virtualWidth: 1920 }) disables the virtual screen for the whole scene and discards a valid size passed to any addUiRenderer. The SDK logs it once per size, but the scene still loses its virtual screen. Never emit a single dimension.
API (verified against @dcl/react-ecs, file dist/system.d.ts):
type UiScreenInset = 'device' | 'interactable' | 'none'
type UiRendererOptions = {
virtualWidth?: number // optional
virtualHeight?: number // optional
screenInset?: UiScreenInset // defaults to 'device'
}
setUiRenderer(ui: UiComponent, options?: UiRendererOptions): void
addUiRenderer(entity: Entity, ui: UiComponent, options?: UiRendererOptions): void
screenInset: which screen area the UI sits in
screenInset picks the area a renderer's UI is positioned in. It defaults to 'device', so UI is kept clear of the notch, status bar and rounded corners out of the box.
| Value | Area |
|---|---|
'device' (default) |
Device safe area, from UiCanvasInformation.screenInsetArea. Zero on desktop, so a no-op there. |
'interactable' |
Area the client designates for scene UI, from UiCanvasInformation.interactableArea. Clears the minimap, chat, and left-side controls, but the bottom-right action buttons are drawn over this area by design — UI placed there competes for taps. |
'none' |
Whole screen, 0,0 at the top-left corner. |
Unlike the virtual size, this is per renderer — the main UI and each addUiRenderer widget can use different areas at the same time.
Do NOT wrap UI in <ScreenInsetArea> / <InteractableArea> while leaving the matching screenInset on the renderer — the inset gets applied twice and the UI is pushed inwards by double the margin. Rely on screenInset, or pass screenInset: 'none' and place the wrapper yourself.
SDK VERSION GATE: 7.26.0 changed three UI-layout behaviors
Check the scene's @dcl/sdk version in its package.json before relying on any of the three rows below. All of them are 7.26.0+ behavior; a scene pinned below that gets the "Below 7.26.0" column instead. "latest", "^7.x" or a fresh create-scene project means current, so assume 7.26.0+ unless the pin says otherwise.
| Behavior | 7.26.0 and later | Below 7.26.0 |
|---|---|---|
| Virtual screen default | Omitting the size applies 1920x1080 (1600x720 mobile); a 16:9 size is overridden to 1600x720 on mobile; <= 0 disables scaling |
No default at all — omitting the size means raw canvas pixels, no scaling. virtualWidth/virtualHeight are required when the options object is passed |
screenInset option |
Exists, defaults to 'device' — UI is inset from the device safe area automatically |
Does not exist. Passing it is a type error. To inset UI you must wrap it in <ScreenInsetArea> / <InteractableArea> yourself |
UI scale factor and vw/vh |
Math.min(canvasWidth / virtualWidth, canvasHeight / virtualHeight); 1vw is 1% of canvas width, as in CSS |
Both additionally divide by devicePixelRatio, so the same pixel value renders smaller on a high-density screen |
What this means when writing code:
- Passing the virtual size explicitly (the default rule) is version-safe — it produces the same layout on both sides. Prefer it always.
screenInsetis not version-safe. Only emit it when the scene is on 7.26.0+. Below that, wrap in<ScreenInsetArea>instead — and note the wrapper is correct there, since there is no renderer-level inset to double up with.- Any code that recomputes the scale factor by hand must match the scene's SDK version — most commonly drag sliders. Below 7.26.0 the
devicePixelRatiodivisor belongs in that formula; from 7.26.0 it does not. Getting it wrong makes drags over- or under-shoot on high-density screens. See{baseDir}/references/ui-sliders.md. - When migrating a scene up to 7.26.0+, expect two visible shifts: UI that previously had no scaling now scales against a default virtual screen, and UI gains a device inset on mobile. An existing
<ScreenInsetArea>wrapper starts double-applying — drop it or passscreenInset: 'none'.
Canonical snippet (use this verbatim unless the user specifies otherwise):
import { ReactEcsRenderer } from '@dcl/sdk/react-ecs'
export function setupUi() {
ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 })
}
Core Components
UiEntity — Container element. Key props: uiTransform (width, height, positionType, position, flexDirection, justifyContent, alignItems, alignContent, alignSelf, padding, margin, display, overflow, flexWrap, flexGrow, opacity, zIndex, borderWidth, borderColor, borderRadius, pointerFilter), uiBackground (color, texture, textureMode, textureSlices, uvs, avatarTexture), uiText (value, fontSize, color, textAlign, font). Events: onMouseDown, onMouseUp, onMouseEnter, onMouseLeave.
These four are the complete set of UI event handlers, and each is
() => void. There is noonMouseDrag/onMouseMove, and no arguments — no pointer coordinates, no event object — are passed to a handler. All four are hardcoded toInputAction.IA_POINTER; you cannot bind a UI element to right-click or a key. Drag interactions are still fully possible viaPrimaryPointerInfo.screenDelta— see "Sliders" below.opacity(number 0–1): fades the element. Set on the root to fade the whole UI; cascades multiplicatively to children.zIndex(number, incl. negative): controls stacking order among sibling elements. Higher = on top. Does not cross parent boundaries.borderWidth/borderColor(Color4) /borderRadius: also valid onButton,Input,Dropdownvia theiruiTransform.width/heightaccept a number (px),'50%','400px', or'auto'.position/padding/marginvalues accept the same string forms;marginalso accepts a CSS shorthand string, e.g.margin: '16px 0 8px 270px'.
Label — Text display. Key props: value, fontSize, color, textAlign (e.g. 'middle-center'), font ('sans-serif'|'serif'|'monospace'), uiTransform. Always give it an explicit width/height in uiTransform, and no emoji in value — see the gotchas below.
Button — Clickable button. Key props: value, variant ('primary'|'secondary'), fontSize, onMouseDown, uiTransform.
Input — Text input field. Key props: placeholder, fontSize, color, onChange, onSubmit, uiTransform.
Dropdown — Selection dropdown. Key props: options (string[]), selectedIndex, onChange, fontSize, uiTransform, disabled.
ScreenInsetArea — Wrapper that keeps children inside the device's hardware-reserved margins (notch, status bar, home indicator, rounded corners). Usually unnecessary now: screenInset defaults to 'device', which already does this for the whole renderer. Reach for the component only when the renderer opted out with screenInset: 'none' and you want to protect just one subtree — wrapping on top of the default double-applies the inset. On mobile it positions itself absolutely using the insets the device reports; on desktop the insets are (0,0,0,0), so it's a no-op. It owns its own positionType and position; any values you pass for those in uiTransform are ignored. All other uiTransform props (padding, flexDirection, alignItems, …) and components (uiBackground, onMouseDown, …) work as usual. A child sized width: '100%', height: '100%' fills the safe area exactly. It auto-compensates for the UI scale factor (pre-divides insets so the parser's scale multiplication cancels out), so insets are correct regardless of virtual screen size. Distinct from the Decentraland system HUD reserved zones (joystick, chat, profile, interaction button) — avoid those with screenInset: 'interactable' or by hand, or, for the mobile input controls specifically, hide them outright with TouchScreenControls (see advanced-input). Do not apply the old "scale sizes ~3× for mobile" rule of thumb on 7.26.0+: with devicePixelRatio out of the scale factor, pixel-sized UI is already ~2–3× larger on a phone than it used to be, and the 1600x720 mobile virtual screen adds ~1.2× on top. Start from the desktop sizes and only scale up what actually measures too small on a device.
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%. Prefer screenInset: 'interactable' on the renderer for a whole-UI application; use the component for a single subtree, or when the renderer uses a different inset. Either form needs an explorer that reports the area: it works on desktop, and on mobile from client 1.12.1 onwards — older mobile clients report no margins and the inset silently does nothing. Like ScreenInsetArea, it owns positionType/position (values you pass are ignored), auto-compensates for the UI scale factor, and falls back to zero insets (no-op) when unavailable. Import from @dcl/sdk/react-ecs; usage <InteractableArea><MyHud /></InteractableArea>. Distinct from ScreenInsetArea (which avoids device hardware margins, not client UI). See {baseDir}/references/ui-components.md → InteractableArea.
UiInputBinding (bind InputActions to UI elements)
The uiInputBinding prop on UiEntity binds InputAction values to a UI element so they fire continuously while it is pressed (touch or pointer). This is the primary mechanism for on-screen action buttons on mobile where there is no keyboard.
import { InputAction } from '@dcl/sdk/ecs'
<UiEntity
uiTransform={{ width: 80, height: 80 }}
uiBackground={{ color: Color4.Red() }}
uiInputBinding={{ actions: [InputAction.IA_JUMP] }}
/>
While the element is held down, InputAction.IA_JUMP fires as if the player were pressing the spacebar. Multiple actions can be bound to one element. The underlying ECS component is PBUiInputBinding { actions: InputAction[] }.
Combine with TouchScreenControls (see the advanced-input skill) for full mobile control customization: hide the native on-screen buttons, then bind the same actions to your own UI. Verified against js-sdk-toolchain commit 82368ee4.
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.
A scene that only ever calls addUiRenderer (no setUiRenderer at all) still gets the platform default virtual screen and the default 'device' inset — the defaults are not tied to the main renderer. The virtual size passed here is ignored if setUiRenderer already passed one; screenInset is always honored per renderer.
State Management
Use module-level variables for UI state — React hooks (useState, useEffect, 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 —
uiBackgroundwithtextureandtextureMode: 'stretch' - Screen dimensions — Read via
UiCanvasInformation.getOrNull(engine.RootEntity) - Nine-slice textures —
textureMode: 'nine-slices'withtextureSlicesfor scalable panels - Texture UVs / Sprite sheets —
uvsarray (8 numbers) to select texture regions - Hover events —
onMouseEnter/onMouseLeaveon UiEntity - Flex wrap —
flexWrap: 'wrap'for grid layouts - Scrollable containers —
overflow: 'scroll'on a fixed-size parent to scroll through overflowing content (drag or mouse wheel). Useoverflow: 'hidden'to clip overflow without scrolling. UseflexGrow: 1on scrollable entities to fill remaining space - Texture tint — set
coloralongsidetextureinuiBackgroundto tint the image (works withstretchandnine-slices) - 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 —
opacityandzIndexonuiTransform(see Core Components); rootopacityfades the whole HUD
Gotchas (verified against engine test scenes)
InputandDropdownare uncontrolled.onChange/onSubmitfire with the current value, but the field does not read back from thevalue/selectedIndexprop you pass each frame the way React does. To programmatically clear anInput, briefly setvalueto a non-empty sentinel (e.g.' ') for one frame, then back to''. Do not expect settingvalueto force the displayed text every frame.zIndexis 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.opacitymultiplies down the tree. A child atopacity: 0.8inside a root atopacity: 0.5renders at 0.4 effective. Don't stack opacities unintentionally.textureMode: 'stretch'deforms non-uniform art; use'nine-slices'(withtextureSlices) for panels/buttons that must scale without distorting borders, and'center'to draw the texture at native size centered in the element.Give every
Labelan explicituiTransformbox — text intrinsic sizing is engine-dependent. ALabelwith an unsetwidth/heightis sized from its rendered glyphs on some engines and contributes ~0 to layout on others, while its glyphs still draw anchored on the zero-height node. Consequences on the engines that don't measure: labels stacked in a column overlap each other, and any parent auto-sizing from text children collapses to its padding. Verified in-world with side-by-side screenshots: a dialog whose labels hadwidth: '100%',textWrap="wrap"and noheight, inside an auto-sized panel, rendered correctly on the Bevy explorer and came out squashed on the Unity explorer — both labels drawn on top of each other, the panel collapsed to padding + button height.Engine Unset text dimension Bevy explorer measures rendered text, feeds intrinsic height back into flex layout — looks correct Unity explorer contributes ~0 to layout; glyphs still render on the zero-height node → overlap and collapse Creator Hub UI editor canvas shows the unset dimension as 0 (a third behavior — see the editable-ui skill) Rules that follow:
- Every
Label(andButtontext) that participates in layout declares a px or percentwidthANDheight. - A wrapped multi-line label needs a height sized for its line count — two lines at
fontSize: 20→height: 60. - A label that fills a fixed-size parent can just use
width: '100%', height: '100%'. - Containers that stack labels in a column carry explicit heights too rather than auto-sizing from their text children.
Like the emoji gotcha below, this is engine-dependent, so a preview that looks right in one explorer proves nothing about the others — the layout is only correct once the boxes are explicit.
- Every
Never put emoji in UI text. No emoji in any
Label/Buttonvalue,uiText.value,Inputplaceholder, orDropdownoption. Emoji glyph coverage is not provided by the SDK — it depends on the fonts each explorer bundles, and the Unity explorer has no emoji glyphs, so an emoji renders as a missing-glyph box or silently as nothing. This varies per engine, which makes it a trap: the same string can look correct in one explorer and be broken or invisible in another, so a preview in one client proves nothing. Verified in-world:value="✨ Particles"rendered without the sparkle on the Unity explorer. Use plain text for the label, and get pictorial affordances from art you ship: auiBackgroundwithtexture: { src: 'images/icon.png' }on a smallUiEntitybeside the text, or a sprite from an atlas viauvs. The same caution applies to other decorative Unicode (arrows, box-drawing, dingbats) — stick to ASCII plus the accented letters your copy actually needs.Texture
srcpaths are relative to the scene root (e.g.'images/panel.png'), not tosrc/.No pointer coordinates in UI handlers.
onMouseDown/onMouseUp/onMouseEnter/onMouseLeaveare() => void— the reconciler discards thePBPointerEventsResultbefore calling your callback, so "where on this element did they click" is unavailable. Track movement instead of position:PrimaryPointerInfo.screenDeltareports per-frame mouse travel and drives drag interactions fine. See{baseDir}/references/ui-sliders.md.UI elements with a handler become pointer-blocking, over their WHOLE rect. Adding any one of the four listeners makes the element capture pointer input across its entire box — not just where its visible pixels are — blocking clicks to the 3D world and to every UI element behind it. An element with no listeners and the default
pointerFilter: 'none'lets clicks through.pointerFilter: 'block'does the same capture without a listener. A transparent background changes nothing: capture follows the layout box, not visibility.NEVER put a pointer handler (or
pointerFilter: 'block') on a full-screen100%×100%wrapper. This is the single highest-severity UI mistake: the wrapper's rect is the whole screen, so one strayonMouseDownon the layout root makes the player unable to click any other UI element or anything in the world — while the UI still looks correct, because the visible panel occupies a fraction of the screen. Attach handlers only to the smallest element that needs them: the panel, the button, the row. Layout wrappers stay handler-free. Two blocking full-screen overlays are legitimate, and both must be a deliberate, gated decision rather than a side effect: a modal backdrop that is supposed to swallow clicks while it is open, and a drag-release catcher that exists only while a drag is active (see{baseDir}/references/ui-sliders.md).
Common Widgets — Build From Scratch
Build every widget from React-ECS primitives (UiEntity, Label, Button). There is no pre-built widget library to install.
- Prompt / dialog / confirmation? → full-screen overlay + centered panel +
Buttons. See the Modal Dialog pattern inreferences/ui-components.md. - Health bar, progress bar, score? → nested
UiEntitywith the inner one sizedwidth:${pct}%``. See the Health Bar patterns inreferences/ui-components.mdandreferences/ui-patterns.md; a score is aLabelbound to a module-level variable. - Flash announcement (timed, centered)? → a centered
Labelgated on a module-level flag, cleared withtimers.setTimeout. See Timed Announcement inreferences/ui-patterns.md. - Slider / drag handle / scrub bar? → drag sliders work. UI handlers get no pointer coordinates, so instead:
onMouseDownon the track starts a drag, and a system accumulatesPrimaryPointerInfo.screenDelta.x(divided by the UI scale factor) into the value. A full-screenpointerFilter: 'block'overlay rendered only while dragging catches the release. Verified in-world on both the Unity and Bevy explorers. Desktop only —screenDeltais always 0 on mobile, so pair the track with-/+stepperButtons. 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, display: 'none', 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) | setupUi() is not called from main() in src/index.ts — users sometimes remove or comment out this call |
Add the setupUi() call inside main(). Always check this first. |
UI not rendering even though setupUi() is called |
ReactEcsRenderer.setUiRenderer(...) missing from setupUi() itself |
Add ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 }) |
| UI blank on first frames, sometimes appears later | Root component returns null (or falsy) on first render with no fallback |
Render a placeholder or hidden root instead of returning null |
| Multiple UIs fighting / UI missing | More than one setUiRenderer call — later calls replace earlier ones, so only the last one wins |
Only call setUiRenderer once — combine all UI into a single root component, or use addUiRenderer with separate owner entities |
| Absolute-positioned children laid out unexpectedly | Root <UiEntity> has no width/height — 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 flexDirection or wrong layout |
Set flexDirection: 'column' on the parent container |
| Button clicks not registering | Missing onMouseDown handler |
Add onMouseDown={() => { ... }} to the Button or UiEntity |
| Nothing on screen is clickable any more — other UI elements dead, 3D world unclickable, cursor does nothing | A full-screen (100%×100%) wrapper carries a pointer handler or pointerFilter: 'block'. Its rect is the whole screen, so it captures every click even though only a small panel is visible |
Strip all listeners and pointerFilter: 'block' from the layout wrapper; move them onto the panel/button that actually needs them. See the pointer-blocking gotchas above |
| JSX errors at compile time | File extension is .ts instead of .tsx |
Rename the file to .tsx |
| Text not visible | Text color matches background | Set contrasting color on Label or uiText |
| UI looks right on one explorer but labels overlap / the panel is squashed on another (Unity) | Labels with no explicit width/height, and/or a container auto-sizing from its text children. Bevy measures text and lays it out; Unity gives the unset dimension ~0 while still drawing the glyphs, so stacked labels collide and the parent collapses |
Give every Label an explicit uiTransform box (wrapped text: height = line count × line height) and an explicit height to every container stacking labels. See the text-sizing gotcha above |
| Part of a string missing, or shows as an empty/□ box — often an icon character | Emoji or other decorative Unicode in the text. The explorer has no glyph for it (the Unity explorer ships no emoji glyphs) and renders nothing or a missing-glyph box | Remove the emoji; use a uiBackground.texture icon on a small UiEntity beside the label instead. Engine-dependent, so verify on the target explorer, not just one |
Convention: root <UiEntity> must set width: '100%', height: '100%'
Set uiTransform={{ width: '100%', height: '100%' }} on the root <UiEntity> returned to setUiRenderer / addUiRenderer 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/margin (no absolute children) use a smaller root (e.g. 90% or 50%) and render fine — but a full-canvas root is the safe default and never hurts.
The corollary: that full-canvas root — and every other 100%×100% wrapper the convention produces — must stay pointer-transparent. It exists to define a positioning context, nothing else. Give it uiTransform and uiBackground only; never a listener, never pointerFilter: 'block'. A handler there captures pointer input over the entire screen and silently kills every other click in the scene (see the pointer-blocking gotchas above). This is the trap the convention creates, so check it every time you add a handler: is this element the smallest one that needs it?
Rationale (empirically verified — tested in-engine June 2026):
- Without a full-canvas root, absolute-positioned children using
position: { top, right }may fail to render entirely. In testing, a root with no explicitwidth/heightcaused atop-rightpositioned child to disappear while abottom-leftchild rendered correctly. Addingwidth: '100%', height: '100%'to the root fixed the issue. - A full-canvas root gives absolute-positioned children (
positionType: 'absolute'withposition: { 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
width/height.
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 —
zIndex(incl. negative) andopacityonuiTransform, including root-level opacity cascade; buttons cycle values. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/70,-9-sdk7-ui-backgrounds — every
uiBackgroundtexture mode (stretch,nine-slices,center), color tinting over textures,avatarTexture, andtextureSlices. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/80,-3-ui —
Label/Input/Dropdown/Buttonend to end,uiTextonUiEntity,marginCSS-shorthand strings,'auto'sizing,UiCanvasInformation. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/81,-3-ui-2 — array-return of stacked panels,
disabledtoggling, border props (borderWidth/borderColor/borderRadius) on Input/Dropdown/Button, uncontrolled-input clear trick, texturedButton(nine-slices) vs. clickableUiEntity. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/76,-10-UiCanvasInformation — reading
UiCanvasInformationeach frame into a module variable to size UI responsively. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/81,-2-ui-screen-inset-area — the three
screenInsetmodes ofsetUiRenderer/addUiRenderer('none','device','interactable') as three coexisting renderers, each framing the area it is positioned in and printing the liveUiCanvasInformation.screenInsetArea/.interactableAreavalues. - 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"inscene.json(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
- UI that must be editable in the Creator Hub: the Creator Hub's 2D UI editor (UI Designer) parses the scene's real
.tsxfiles undersrc/ui/as its document, and only a subset of React-ECS code round-trips. If the user wants to design or restyle the UI visually in the Creator Hub, follow the editable-ui skill instead of writing free-form React-ECS — computed style values, loops, conditionals and unknown elements silently become read-only there. - Platform detection: Use
getPlatform()/isMobile()from@dcl/sdk/platformto branch UI for mobile vs. desktop. See the advanced-input skill. - Mobile UI limitations:
borderRadiusis unsupported on mobile. Design for touch (larger tap targets, no hover states). See the mobile considerations in the advanced-input skill. - Replacing the native mobile controls: the on-screen joystick, crosshair, and gamepad buttons are not fixed —
TouchScreenControls(SDK 7.26.0+, see advanced-input) hides any of them so scene UI can take their place, withUiInputBinding(above) wiring the replacement buttons to InputActions.