Skip to main content
graphwiz.aigraphwiz.ai
← Back to XR

Jetpack Compose for XR: Building Spatial Layouts with Kotlin

XR
android-xrjetpack-compose-xrspatial-uikotlinandroid

Ten million Android developers already know Jetpack Compose. With Compose for XR, that knowledge transfers directly into 3D space. No new language, no scene graph API to memorise, no Unity project to bootstrap. You write Kotlin, you use the same declarative patterns, and the framework handles depth, perspective, and eye-tracking focus for you.

The SDK is at version 1.0.0-alpha12 (released March 2026) and moving fast. The API surface is stabilising around a small set of primitives that map cleanly to Compose concepts you already know.

Subspaces: Where 2D Meets 3D

Everything spatial starts with Subspace. It creates a partition of 3D space within your regular Compose UI tree. Anything inside it renders at the system's recommended position and scale. On non-XR devices, the entire block is a no-op.

if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
    Subspace {
        SpatialPanel(
            SubspaceModifier
                .height(824.dp)
                .width(1400.dp)
        ) {
            // Your existing Compose UI renders on a 2D plane in 3D space
            MyExistingScreen()
        }
    }
} else {
    MyExistingScreen()  // Falls back to 2D on phones/tablets
}

The LocalSpatialCapabilities check is the branching point. On XR hardware, you enter the subspace. Everywhere else, you render normally. This means you can maintain a single codebase for phone, tablet, and headset without duplicating layouts.

SpatialPanel is the workhorse. It renders a 2D plane in 3D space. Maximum size: 2560dp by 1800dp. The system recommends panels spawn roughly 1.75 metres from the user's line of sight, and auto-scales content between 0.75m and 1.75m based on distance. If you need a panel that stays the same perceived size regardless of distance, set shouldScaleWithDistance to false.

Spatial Layouts: Row, Column, CurvedRow

This is where things get interesting. Compose gives you Row and Column. Compose for XR gives you SpatialRow, SpatialColumn, and SpatialCurvedRow, which arrange spatial panels in 3D space using the same mental model.

Subspace {
    SpatialRow {
        SpatialColumn {
            SpatialPanel(
                SubspaceModifier.height(250.dp).width(400.dp)
            ) {
                NavigationPanel()
            }
            SpatialPanel(
                SubspaceModifier.height(200.dp).width(400.dp)
            ) {
                SecondaryContent()
            }
        }
        SpatialPanel(
            SubspaceModifier.height(824.dp).width(1400.dp)
        ) {
            MainContent()
        }
    }
}

SpatialCurvedRow is the one that feels genuinely new. It arranges children along a curve, wrapping them around the user's field of view. The curveRadius parameter controls curvature: a larger radius produces a gentle arc, smaller values create a tighter wrap.

Subspace {
    SpatialCurvedRow(
        curveRadius = 1025.dp,
        verticalAlignment = SpatialAlignment.CenterVertically,
        horizontalArrangement = SpatialArrangement.Center,
    ) {
        SpatialPanel(SubspaceModifier.weight(0.3f)) {
            Text("Left")
        }
        SpatialPanel(SubspaceModifier.weight(0.5f)) {
            Text("Centre")
        }
        SpatialPanel(SubspaceModifier.weight(0.3f)) {
            Text("Right")
        }
    }
}

This is particularly useful for media dashboards, surveillance grids, and any scenario where you want content to wrap the user's peripheral vision naturally.

2D vs Spatial: A Quick Mapping

Compose 2D Compose XR Purpose
Row SpatialRow Horizontal arrangement
Column SpatialColumn Vertical arrangement
Box SpatialBox Overlapping elements
Modifier SubspaceModifier Sizing, offset, depth
Dialog SpatialDialog Modal overlays
Popup SpatialPopup Floating context menus
(none) SpatialCurvedRow Curved horizontal layout
(none) Orbiter Floating panel attachments
(none) Volume 3D model subspace

Orbiters: Floating UI Anchors

An Orbiter is a floating element anchored to a spatial panel or layout. Think of it as a toolbar that hovers near your content without being part of the panel itself.

Subspace {
    SpatialRow {
        Orbiter(
            position = ContentEdge.Top,
            offset = 8.dp,
            offsetType = OrbiterOffsetType.InnerEdge,
            alignment = Alignment.CenterHorizontally,
        ) {
            Surface(Modifier.clip(CircleShape)) {
                IconButton(onClick = { /* toggle settings */ }) {
                    Icon(Icons.Default.Settings, contentDescription = "Settings")
                }
            }
        }
        SpatialPanel(
            SubspaceModifier.height(824.dp).width(1400.dp)
        ) {
            MainContent()
        }
    }
}

The position parameter (ContentEdge.Top, Bottom, Start, End) defines which edge of the parent the orbiter anchors to. offset controls how far it floats. Orbiters are the right place for FABs, toolbars, and status indicators that need to stay close to a panel but outside its content area.

3D Content: Models and Volumes

Alpha 12 introduced SpatialGltfModel for rendering glTF assets directly in a subspace. For lower-level control, SceneCoreEntity wraps any SceneCore entity (the underlying 3D engine) in a composable.

Subspace(allowUnboundedSubspace = true) {
    SpatialGltfModel(
        model = "models/robot.glb",
        modifier = SubspaceModifier
            .width(400.dp)
            .height(400.dp),
    ) { state ->
        // Control animations via SpatialGltfModelAnimation
        state.animations.firstOrNull()?.let { anim ->
            anim.play()
        }
    }
}

Volume is a specialised subspace designed specifically for 3D entities. It provides the spatial bounds and rendering context that 3D models need, separate from the 2D panel hierarchy.

SpatialExternalSurface handles video and image content, including flat, 180-degree hemisphere, and full 360-degree sphere projections. This is the API for immersive media players.

Subspace {
    SpatialExternalSurface(
        stereoMode = StereoMode.SideBySide,
        modifier = SubspaceModifier
            .width(1200.dp)
            .height(676.dp),
    ) {
        // Access the Surface to render video frames
        val surface = this.surface
        videoPlayer.setSurface(surface)
    }
}

For nesting 3D content inside a panel, PlanarEmbeddedSubspace creates a subspace within a SpatialPanel, letting you embed a 3D viewport inside a 2D layout, much like a WebView inside an Android activity.

Spatial UI Components

SpatialDialog, SpatialPopup, and SpatialElevation are the spatial equivalents of their 2D counterparts. They render in 3D space with proper depth sorting and eye-tracking focus.

var showDialog by remember { mutableStateOf(false) }

if (showDialog) {
    SpatialDialog(
        onDismissRequest = { showDialog = false },
        properties = SpatialDialogProperties(
            dismissOnBackPress = true,
            dismissOnClickOutside = true,
        ),
    ) {
        Surface(shape = MaterialTheme.shapes.large) {
            Column(Modifier.padding(24.dp)) {
                Text("Confirm action?", style = MaterialTheme.typography.headlineSmall)
                Spacer(Modifier.height(16.dp))
                Row(horizontalArrangement = Arrangement.End) {
                    TextButton(onClick = { showDialog = false }) { Text("Cancel") }
                    Button(onClick = { /* confirm */ showDialog = false }) { Text("Confirm") }
                }
            }
        }
    }
}

Eye-tracking focus is automatic. When a user looks at a spatial element, it receives focus as if they had tapped it. Your existing focus handlers work without changes.

Getting Started

You need Android Studio Preview Narwhal (or later) with the XR emulator plugin. The emulator simulates the headset's field of view and head tracking on your development machine.

1. Add the dependency to your module's build.gradle.kts:

dependencies {
    implementation("androidx.xr.compose:compose:1.0.0-alpha12")
    implementation("androidx.xr.scenecore:scenecore:1.0.0-alpha12")
    testImplementation("androidx.xr.compose:compose-testing:1.0.0-alpha12")
}

2. Check spatial capabilities before entering subspace code:

val isSpatial = LocalSpatialCapabilities.current.isSpatialUiEnabled

3. Create your first spatial layout by wrapping existing UI in Subspace and SpatialPanel:

setContent {
    MaterialTheme {
        if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
            Subspace {
                SpatialPanel(
                    SubspaceModifier
                        .height(824.dp)
                        .width(1400.dp),
                ) {
                    MyExistingApp()
                }
            }
        } else {
            MyExistingApp()
        }
    }
}

4. Test on the emulator, then graduate to a Samsung Galaxy XR headset for real-world interaction.

The official documentation is at developer.android.com/develop/xr. The Android XR codelab ("Learn Android XR Fundamentals: Part 1") walks through modes and spatial panels in about 30 minutes. Google's android/snippets repository on GitHub contains working code for every API discussed here.

What to Build Next

Start with something you already have. Take an existing Compose app, wrap its main screen in Subspace + SpatialPanel, and see it floating in 3D. Then add a SpatialCurvedRow with supplementary panels. Then try an Orbiter for floating controls. The SDK is designed so each spatial feature is an incremental step, not a rewrite.

For something more ambitious, the Volume + SpatialGltfModel combination opens up product visualisation, architectural walkthroughs, and data visualisation in three dimensions. The API is still alpha, and some rough edges remain, but the mental model is solid: Compose, but in space.