# Build UI

> Build 2D screen-space UI for Decentraland scenes using React-ECS (JSX). Create HUDs, menus, health bars, scoreboards, dialogs, buttons, inputs, and dropdowns. Use when the user wants screen overlays, on-screen UI, HUD elements, menus, or form inputs. Do NOT use for 3D in-world text (see advanced-rendering) or clickable 3D objects (see add-interactivity).

- Skill: `dcl-regenesislabs/build-ui` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add dcl-regenesislabs/build-ui`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dcl-regenesislabs/build-ui/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: dcl-regenesislabs (https://skillmd.com/u/dcl-regenesislabs)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/dcl-regenesislabs/build-ui

---


# 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
```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
```typescript
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)
```tsx
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)
```tsx
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
```tsx
<Button
  value="Click Me"
  variant="primary"  // 'primary' | 'secondary'
  fontSize={16}
  uiTransform={{ width: 150, height: 40 }}
  onMouseDown={() => {
    console.log('Button clicked!')
  }}
/>
```

### Input
```tsx
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
```tsx
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):

```tsx
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
```tsx
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
```tsx
<UiEntity
  uiTransform={{ width: 200, height: 200 }}
  uiBackground={{
    textureMode: 'stretch',
    texture: { src: 'images/logo.png' }
  }}
/>
```

### Screen Dimensions

Read screen size via `UiCanvasInformation`:

```typescript
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:

```tsx
<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:

```tsx
<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:

```tsx
<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:

```tsx
<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.

```tsx
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'` 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`.

