build-ui

Installation
SKILL.md

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() {
  ReactEcsRenderer.setUiRenderer(MyUI)
}

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}
/>

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 Only call setUiRenderer once — combine all UI into a single root component
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' in uiTransform to hide elements without removing them
  • File extension must be .tsx for 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.

Related skills

More from dcl-regenesislabs/opendcl

Installs
26
GitHub Stars
14
First Seen
Feb 25, 2026