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
File: src/ui.tsx
import ReactEcs, { ReactEcsRenderer, UiEntity, Label, Button } from '@dcl/sdk/react-ecs'
const MyUI = () => (
<UiEntity
uiTransform={{
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Label value="Hello Decentraland!" fontSize={24} />
</UiEntity>
)
export function setupUi() {
// ALWAYS pass virtualWidth + virtualHeight — the renderer scales the layout
// to fit the player's window using these as the reference. Without them,
// sizes are interpreted in raw pixels and won't behave consistently across
// resolutions and aspect ratios.
ReactEcsRenderer.setUiRenderer(MyUI, { virtualWidth: 1920, virtualHeight: 1080 })
}
File: src/index.ts
import { setupUi } from './ui'
export function main() {
setupUi()
}
tsconfig.json (already configured by /init)
The SDK template already includes the required JSX settings — do NOT modify tsconfig.json:
"jsx": "react-jsx""jsxImportSource": "@dcl/sdk/react-ecs-lib"
Core Components
UiEntity (Container)
import { Color4 } from '@dcl/sdk/math'
<UiEntity
uiTransform={{
width: 300, // Pixels or '50%'
height: 200,
positionType: 'absolute', // 'absolute' or 'relative' (default)
position: { top: 10, right: 10 }, // Only with absolute
flexDirection: 'column', // 'row' | 'column'
justifyContent: 'center', // 'flex-start' | 'center' | 'flex-end' | 'space-between'
alignItems: 'center', // 'flex-start' | 'center' | 'flex-end' | 'stretch'
padding: { top: 10, bottom: 10, left: 10, right: 10 },
margin: { top: 5 },
display: 'flex' // 'flex' | 'none' (hide)
}}
uiBackground={{
color: Color4.create(0, 0, 0, 0.8) // Semi-transparent black
}}
/>
Label (Text)
import { Color4 } from '@dcl/sdk/math'
<Label
value="Score: 100"
fontSize={18}
color={Color4.White()}
textAlign="middle-center"
font="sans-serif"
uiTransform={{ width: 200, height: 30 }}
/>
Button
<Button
value="Click Me"
variant="primary" // 'primary' | 'secondary'
fontSize={16}
uiTransform={{ width: 150, height: 40 }}
onMouseDown={() => {
console.log('Button clicked!')
}}
/>
Input
import { Input } from '@dcl/sdk/react-ecs'
import { Color4 } from '@dcl/sdk/math'
<Input
placeholder="Type here..."
fontSize={14}
color={Color4.White()}
uiTransform={{ width: 250, height: 35 }}
onChange={(value) => {
console.log('Value changing:', value)
}}
onSubmit={(value) => {
console.log('Submitted:', value)
}}
/>
Dropdown
import { Dropdown } from '@dcl/sdk/react-ecs'
<Dropdown
options={['Option A', 'Option B', 'Option C']}
selectedIndex={0}
onChange={(index) => {
console.log('Selected:', index)
}}
uiTransform={{ width: 200, height: 35 }}
fontSize={14}
/>
State Management
Use module-level variables for UI state (React hooks are NOT available):
import { Color4 } from '@dcl/sdk/math'
let score = 0
let showMenu = false
const GameUI = () => (
<UiEntity uiTransform={{ width: '100%', height: '100%' }}>
{/* HUD - always visible */}
<Label
value={`Score: ${score}`}
fontSize={20}
uiTransform={{
positionType: 'absolute',
position: { top: 10, left: 10 }
}}
/>
{/* Menu - conditionally shown */}
{showMenu && (
<UiEntity
uiTransform={{
width: 300,
height: 400,
positionType: 'absolute',
position: { top: '50%', left: '50%' }
}}
uiBackground={{ color: Color4.create(0.1, 0.1, 0.1, 0.9) }}
>
<Label value="Game Menu" fontSize={24} />
<Button
value="Resume"
variant="primary"
onMouseDown={() => { showMenu = false }}
uiTransform={{ width: 200, height: 40 }}
/>
</UiEntity>
)}
</UiEntity>
)
// Update state from game logic
export function addScore(points: number) {
score += points
}
export function toggleMenu() {
showMenu = !showMenu
}
Common UI Patterns
Health Bar
import { Color4 } from '@dcl/sdk/math'
let health = 100
const HealthBar = () => (
<UiEntity
uiTransform={{
width: 200, height: 20,
positionType: 'absolute',
position: { bottom: 20, left: '50%' }
}}
uiBackground={{ color: Color4.create(0.3, 0.3, 0.3, 0.8) }}
>
<UiEntity
uiTransform={{ width: `${health}%`, height: '100%' }}
uiBackground={{ color: Color4.create(0.2, 0.8, 0.2, 1) }}
/>
</UiEntity>
)
Image Background
<UiEntity
uiTransform={{ width: 200, height: 200 }}
uiBackground={{
textureMode: 'stretch',
texture: { src: 'images/logo.png' }
}}
/>
Screen Dimensions
Read screen size via UiCanvasInformation:
import { UiCanvasInformation } from '@dcl/sdk/ecs'
engine.addSystem(() => {
const canvas = UiCanvasInformation.getOrNull(engine.RootEntity)
if (canvas) {
console.log('Screen:', canvas.width, 'x', canvas.height)
}
})
Nine-Slice Textures
Use textureSlices for scalable UI backgrounds (buttons, panels) that don't stretch corners:
<UiEntity
uiTransform={{ width: 200, height: 100 }}
uiBackground={{
textureMode: 'nine-slices',
texture: { src: 'images/panel.png' },
textureSlices: { top: 0.1, bottom: 0.1, left: 0.1, right: 0.1 }
}}
/>
Hover Events
Respond to mouse enter/leave for hover effects:
<UiEntity
uiTransform={{ width: 100, height: 40 }}
onMouseEnter={() => { isHovered = true }}
onMouseLeave={() => { isHovered = false }}
uiBackground={{ color: isHovered ? Color4.White() : Color4.Gray() }}
/>
Flex Wrap
Allow UI children to wrap to the next line:
<UiEntity uiTransform={{ flexWrap: 'wrap', width: 300 }}>
{items.map(item => (
<UiEntity key={item.id} uiTransform={{ width: 80, height: 80, margin: 4 }} />
))}
</UiEntity>
Dropdown Extras
The Dropdown component supports additional props:
<Dropdown
options={['Option A', 'Option B', 'Option C']}
selectedIndex={selectedIdx}
onChange={(idx) => { selectedIdx = idx }}
fontSize={14}
color={Color4.White()}
disabled={false}
/>
Multiple UI Modules (addUiRenderer / removeUiRenderer)
If you have several independent UI modules — e.g., a HUD, a dialog system, a debug overlay — combine them under a single root or use addUiRenderer to mount each module against an owner entity. When the owner entity is deleted, the UI renderer is removed automatically.
import { engine, ReactEcsRenderer } from '@dcl/sdk/react-ecs'
const hudOwner = engine.addEntity()
ReactEcsRenderer.addUiRenderer(hudOwner, () => <HudOverlay />)
// later, when the HUD should disappear:
engine.removeEntity(hudOwner) // also removes the UI renderer
// or explicitly:
ReactEcsRenderer.removeUiRenderer(hudOwner)
Each addUiRenderer mount renders independently. Useful for dynamic UIs that should appear/disappear based on game state without manually conditioning every sub-tree of one giant root component.
Troubleshooting
| Problem | Cause | Solution |
|---------|-------|----------|
| UI not appearing at all | Missing ReactEcsRenderer.setUiRenderer() call | Add ReactEcsRenderer.setUiRenderer(MyUI) in main() or setupUi() |
| 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 |
| JSX errors at compile time | File extension is .ts instead of .tsx | Rename the file to .tsx |
| Multiple UIs fighting | More than one setUiRenderer call | Use ONE setUiRenderer for the main UI; for independent modules use addUiRenderer(ownerEntity, ...) instead |
| Text not visible | Text color matches background | Set contrasting color on Label or uiText |
World interactions instead of screen UI? See the add-interactivity skill for click handlers and pointer events on 3D objects.
Important Notes
- React hooks (
useState,useEffect, etc.) are NOT available — use module-level variables - The UI renderer re-renders every frame, so state changes are reflected immediately
- UI is rendered as a 2D overlay on top of the 3D scene
- Use
display: 'none'inuiTransformto hide elements without removing them - File extension must be
.tsxfor JSX support - Only one
ReactEcsRenderer.setUiRenderer()call per scene — combine all UI into one root component
For full component props (UiEntity, Label, Button, Input, Dropdown), layout patterns, and responsive design, see {baseDir}/references/ui-components.md.
微信扫一扫