Develop
Develop
Select your platform

Build and position your first panel

Updated: Feb 2, 2026

Introduction

This tutorial guides you through creating your first 2D panel in a Spatial SDK application. Panels are floating UI surfaces that display Android UI content in 3D space. By the end of this tutorial, you will have a working panel that displays Jetpack Compose content at a specific position in your scene.
You will start with the StarterSample project, which already contains a working panel implementation. This tutorial shows you how to add a second panel and position it in 3D space.
What you’ll build:
A stylized panel that displays sharp, clear “Hello Spatial SDK!” text in front of the user.
What you’ll learn:
  • How to define panel resources in the StarterSample
  • How ComposeFeature simplifies Compose panel creation
  • How to configure and register a second panel
  • How to spawn a panel entity at a specific position
  • How to adjust panel position and rotation using Transform, Pose, and Vector3

Prerequisites

RequirementDetails
StarterSample
Android Studio
Hedgehog or later
Target device
Meta Quest 3 or 3S
This tutorial uses the StarterSample as the starting point. To set up the StarterSample:
  1. Download the Meta Spatial SDK Samples from GitHub.
  2. Extract the downloaded files to a location on your computer.
  3. Open the StarterSample project folder in Android Studio.

Imports in code samples

Code samples include imports that may already exist in StarterSample. If building from a different starting point, use these imports as reference.

Explore the existing panel implementation

The basic welcome panel that the StarterSample displays in its unedited state.
Before adding a new panel, examine how the StarterSample implements its welcome panel. This gives you a pattern to follow.
Open StarterSample/app/src/main/java/com/meta/spatial/samples/startersample/StarterSampleActivity.kt and locate the registerFeatures() method:
override fun registerFeatures(): List<SpatialFeature> {
    val features = mutableListOf<SpatialFeature>(
        VRFeature(this),
        ComposeFeature(),
    )
    // Debug features omitted for clarity
    return features
}
The ComposeFeature() is already registered, which provides helper functions and classes that simplify working with Compose in panels. For example, ComposeFeature enables the composePanel {} builder syntax used later in this tutorial. Jetpack Compose works in panels without this feature, but ComposeFeature makes common panel patterns more convenient.
Next, locate the registerPanels() method:
override fun registerPanels(): List<PanelRegistration> {
    return listOf(
        ComposeViewPanelRegistration(
            R.id.panel,
            composeViewCreator = { _, ctx ->
                ComposeView(ctx).apply { setContent { WelcomePanel() } }
            },
            settingsCreator = {
                UIPanelSettings(
                    shape = QuadShapeOptions(width = 2.048f, height = 1.254f),
                    style = PanelStyleOptions(themeResourceId = R.style.PanelAppThemeTransparent),
                    display = DpPerMeterDisplayOptions(),
                )
            },
        )
    )
}
Note: This tutorial uses PanelRegistration with composePanel {}, which provides simpler syntax for common use cases. The StarterSample uses ComposeViewPanelRegistration, which offers more explicit configuration. Both approaches are valid.

Step 1: Define a panel resource ID

Panels require a unique integer ID defined in your Android resources. Open StarterSample/app/src/main/res/values/ids.xml and add a new panel hello_panel ID:
<?xml version="1.0" encoding="utf-8" ?>
<resources>
    <item type="id" name="panel" />
    <item type="id" name="hello_panel" />
</resources>
This creates R.id.hello_panel, which you will use to identify your new panel.

Step 2: Create a Composable for the panel

With the resource ID defined, create a Composable function that defines your panel’s content. This initial version uses a minimal SpatialTheme wrapper to ensure the panel renders correctly.
Create a new file at StarterSample/app/src/main/java/com/meta/spatial/samples/startersample/HelloPanel.kt:
package com.meta.spatial.samples.startersample

import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.meta.spatial.uiset.theme.LocalColorScheme
import com.meta.spatial.uiset.theme.SpatialColorScheme
import com.meta.spatial.uiset.theme.SpatialTheme
import com.meta.spatial.uiset.theme.darkSpatialColorScheme
import com.meta.spatial.uiset.theme.lightSpatialColorScheme

@Composable
fun HelloPanel() {
    SpatialTheme(colorScheme = getPanelTheme()) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(brush = LocalColorScheme.current.panel),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Hello Spatial SDK!",
                fontSize = 32.sp,
                color = SpatialTheme.colorScheme.primaryAlphaBackground
            )
        }
    }
}

@Composable
private fun getPanelTheme(): SpatialColorScheme =
    if (isSystemInDarkTheme()) darkSpatialColorScheme() else lightSpatialColorScheme()
This creates a simple panel with:
  • SpatialTheme wrapper for proper rendering
  • LocalColorScheme.current.panel for the translucent background
  • Basic centered text

Step 3: Register the panel

Update registerPanels() in StarterSampleActivity.kt to include your new panel. Add the new registration to the existing list:
import com.meta.spatial.compose.composePanel
import com.meta.spatial.toolkit.PanelRegistration

override fun registerPanels(): List<PanelRegistration> {
    return listOf(
        // Existing welcome panel (keep as-is)
        ComposeViewPanelRegistration(
            R.id.panel,
            // ... existing configuration
        ),
        // New hello panel using simple registration
        PanelRegistration(R.id.hello_panel) {
            config {
                width = 1.5f
                height = 0.8f
                enableTransparent = true
                themeResourceId = R.style.PanelAppThemeTransparent
            }
            composePanel {
                setContent { HelloPanel() }
            }
        }
    )
}
Panel config options:
OptionDescription
width / height
Panel dimensions in meters
enableTransparent
Allows transparency in the panel to show the 3D scene behind it
themeResourceId
Android theme for the panel window
PanelRegistration provides a simpler alternative to ComposeViewPanelRegistration. The config {} block sets panel dimensions and styling, while composePanel {} defines the Compose content.

Step 4: Spawn the panel entity

With the panel registered, spawn it in your scene by creating a panel entity. Locate the onSceneReady() method in StarterSampleActivity.kt and add the panel entity creation:
import com.meta.spatial.core.Entity
import com.meta.spatial.core.Pose
import com.meta.spatial.core.Quaternion
import com.meta.spatial.core.Vector3
import com.meta.spatial.toolkit.Transform
import com.meta.spatial.toolkit.createPanelEntity

override fun onSceneReady() {
    super.onSceneReady()

    // Existing scene setup code...
    scene.setReferenceSpace(ReferenceSpace.LOCAL_FLOOR)
    // ... lighting and skybox setup

    // Spawn the hello panel at a specific position
    Entity.createPanelEntity(
        R.id.hello_panel,
        Transform(Pose(
            Vector3(0f, 0.5f, 1f),
            Quaternion(0f, 180f, 0f))),
    )
}
Position breakdown:
  • Vector3(0f, 0.5f, 1f) positions the panel:
    • x = 0f: Centered horizontally
    • y = 0.5f: 0.5 meters above the floor
    • z = 1f: 1 meter in front of the user
  • Quaternion(0f, 180f, 0f): Rotated 180° around Y axis to face the user
Note: The Spatial SDK uses a left-handed coordinate system. Positive Z extends forward from the user, positive Y points up, and positive X points right.

Step 5: Build and run

Build and deploy the StarterSample by clicking the green play button in Android Studio.
The initial positioning of the new panel, which is too close to the user.
At this position (1 meter away), the panel appears too close for comfortable viewing. You’ll also notice the text is blurry or difficult to read. This is because the panel is rendering as a textured mesh in scene space rather than using compositor layers.
In the next steps, you will reposition the panel and enable compositor layers for crisp, legible text.

Step 6: Adjust panel position and rotation

To reposition your panel, modify the Vector3 values. To rotate it, adjust the Quaternion.
Example: Position panel to the left and rotated 30 degrees:
Entity.createPanelEntity(
    R.id.hello_panel,
    Transform(Pose(
        Vector3(-2f, 1.2f, -1.5f),           // X = -2m, Y = +1.2m, Z = -1.5m
        Quaternion(0f, 210f, 0f)          // 180° to face user + 30° additional rotation
    )),
)
Vector3 world coordinate breakdown:
AxisPositive (+)Negative (-)Origin
X
+X moves right
-X moves left
X = 0 is scene center
Y
+Y moves up
-Y moves down
Y = 0 is floor level
Z
+Z moves forward
-Z moves backward
Z = 0 is user position
Common positions:
Use caseVector3Coordinates
Eye level, centered
(0f, 1.5f, 2f)
X=0, +Y = 1.5m, +Z = 2m
Lower, closer
(0f, 1.0f, 1.5f)
X = 0, +Y = 1m, Z = 1.5m
Left side
(-1.5f, 1.5f, 1.5f)
X = -1.5m, Y = 1.5m, Z = 1.5m
Right side
(1.5f, 1.5f, 1.5f)
X = +1.5m, Y = 1.5m, Z = 1.5m
Above user
(0f, 2.5f, 1f)
X = 0, Y = 2.5m, Z = 1m
The Quaternion constructor with three parameters accepts Euler angles in degrees: Quaternion(pitch, yaw, roll). Pitch rotates around the X axis (tilting up/down), yaw rotates around Y (turning left/right), and roll rotates around Z (tilting sideways).
Note: The SDK provides two Quaternion constructors: the primary constructor Quaternion(w, x, y, z) accepts quaternion components directly, while the convenience constructor Quaternion(pitch, yaw, roll) accepts Euler angles in degrees. This tutorial uses the Euler angle constructor for readability.
Build and run again. The panel’s position is better, but notice that the text is not crisp and easy to read, and its style does not match the WelcomePanel.
The HelloPanel positioned further away, showing blurry text before compositor layers are enabled.
In the next steps, you will enable compositor layers for crisp, legible text.

Step 7: Enable compositor layers for crisp text

To render panel text at native resolution, add layerConfig and related settings to your panel registration. Update the panel configuration in registerPanels():
import com.meta.spatial.compose.composePanel
import com.meta.spatial.runtime.LayerConfig
import com.meta.spatial.toolkit.PanelRegistration

PanelRegistration(R.id.hello_panel) {
    config {
        width = 1.5f
        height = 0.8f
        layerConfig = LayerConfig()
        enableTransparent = true
        themeResourceId = R.style.PanelAppThemeTransparent
    }
    composePanel {
        setContent { HelloPanel() }
    }
}
layerConfig = LayerConfig() enables compositor layers for native-resolution rendering.
Without layerConfig, panels render as low-resolution textured meshes in scene space. With compositor layers enabled, panels render at native display resolution, resulting in crisp, readable text.
Build and deploy again to see the improvement.
The HelloPanel with compositor layers enabled, displaying sharp, legible text.
The text should now appear sharp and legible. However, styling is still required. You’ll add this in the next step.

Step 8: Enhance the panel styling

The StarterSample uses Meta’s SpatialTheme for consistent styling across panels. Update HelloPanel.kt to use this theme system:
package com.meta.spatial.samples.startersample

import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.meta.spatial.uiset.theme.LocalColorScheme
import com.meta.spatial.uiset.theme.SpatialColorScheme
import com.meta.spatial.uiset.theme.SpatialTheme
import com.meta.spatial.uiset.theme.darkSpatialColorScheme
import com.meta.spatial.uiset.theme.lightSpatialColorScheme

@Composable
fun HelloPanel() {
    SpatialTheme(colorScheme = getPanelTheme()) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .clip(SpatialTheme.shapes.large)
                .background(brush = LocalColorScheme.current.panel)
                .padding(48.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
        ) {
            Column(
                modifier = Modifier.widthIn(max = 400.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Text(
                    text = "Hello Spatial SDK!",
                    textAlign = TextAlign.Center,
                    style = SpatialTheme.typography.headline1Strong.copy(
                        color = SpatialTheme.colorScheme.primaryAlphaBackground
                    ),
                )
            }
        }
    }
}

@Composable
private fun getPanelTheme(): SpatialColorScheme =
    if (isSystemInDarkTheme()) darkSpatialColorScheme() else lightSpatialColorScheme()
Key styling elements:
ElementPurpose
SpatialTheme(colorScheme = ...)
Wraps content in Meta’s spatial theme system
clip(SpatialTheme.shapes.large)
Applies rounded corners matching other panels
background(brush = LocalColorScheme.current.panel)
Adds the translucent glass-like background
padding(48.dp)
Consistent inset matching WelcomePanel
SpatialTheme.typography.headline1Strong
Uses the spatial typography for headings
primaryAlphaBackground
Semi-transparent text color for glass effect
widthIn(max = 400.dp)
Constrains text width for readability
getPanelTheme()
Adapts to system dark/light mode

Step 9: Final build

Deploy the updated application with the styled panel
Your HelloPanel now matches the visual style of WelcomePanel, with rounded corners, and clean typography.
The styled HelloPanel with rounded corners, translucent background, and crisp typography.

Summary

You have successfully created and positioned a second 2D panel in the StarterSample application, then styled it to match the existing WelcomePanel. You learned how to:
  • Define a panel resource ID in ids.xml
  • Create a Composable function for panel content
  • Configure and register a panel with PanelRegistration and composePanel {}
  • Spawn a panel entity using Entity.createPanelEntity()
  • Position and rotate panels using Transform, Pose, Vector3, and Quaternion
  • Apply the SpatialTheme for consistent styling across panels

Next steps

Did you find this page helpful?