Compose Multiplatform
Compose Multiplatform (CMP) takes Jetpack Compose — Android's declarative UI toolkit — and ports it to iOS, Desktop (JVM), and Web (Wasm). Combined with KMP for shared logic, you can write a complete app (business logic + UI) once and ship to every platform. As of 2025, CMP is production-ready for Android + Desktop, stable for iOS, and alpha for Web.
What CMP shares
One codebase
- All composables (UI tree)
- Compose state, side effects, animations
- Material 3 theme, typography, shapes
- Resources (strings, images, fonts)
- Navigation (Navigation Compose)
- Compose-based business logic
Where CMP doesn't help
- System-level APIs (camera, sensors, biometric)
- Push notifications (FCM on Android; APNs on iOS)
- Platform services (Google Play, Health Connect, SiriKit)
- Native look-and-feel bridging (can force Material on iOS)
- App Store / Play Console release workflow
- App lifecycle details
Setup
// build.gradle.kts (shared module)
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library)
alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.compose.compiler)
}
kotlin {
androidTarget { /* ... */ }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.navigation.compose)
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
}
}
}
}
The App() entry point
// shared/src/commonMain/kotlin/App.kt
@Composable
fun App() {
MaterialTheme {
val navController = rememberNavController()
Surface {
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(onItemClick = { id -> navController.navigate("detail/$id") }) }
composable("detail/{id}") { backStackEntry ->
DetailScreen(id = backStackEntry.arguments?.getString("id") ?: "")
}
}
}
}
}
@Composable
fun HomeScreen(onItemClick: (String) -> Unit) {
LazyColumn {
items(products) { product ->
Card(
onClick = { onItemClick(product.id) },
modifier = Modifier.fillMaxWidth().padding(8.dp)
) { Text(product.name, modifier = Modifier.padding(16.dp)) }
}
}
}
This App() runs identically on Android, iOS, Desktop, and Web.
Platform entry points
Android
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
iOS
// iosApp/iosApp/iOSApp.swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ComposeView()
.ignoresSafeArea(.keyboard)
}
}
}
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
// shared/src/iosMain/kotlin/MainViewController.kt
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }
Desktop
// desktopApp/src/jvmMain/kotlin/Main.kt
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "My App",
state = rememberWindowState(width = 1200.dp, height = 800.dp)
) {
App()
}
}
Web (Wasm — alpha)
// webApp/src/wasmJsMain/kotlin/Main.kt
fun main() {
ComposeViewport(document.body!!) {
App()
}
}
Resources
CMP has its own resource system (since 1.6):
shared/src/commonMain/composeResources/
├── drawable/
│ ├── ic_cart.xml
│ └── hero.png
├── values/
│ └── strings.xml
├── font/
│ └── inter_regular.ttf
└── files/
└── sample.json
// commonMain
import myapp.shared.generated.resources.Res
import myapp.shared.generated.resources.greeting
import myapp.shared.generated.resources.ic_cart
@Composable
fun Greeting(name: String) {
Text(stringResource(Res.string.greeting, name))
Icon(painterResource(Res.drawable.ic_cart), contentDescription = null)
}
The plugin generates a Res object with compile-time-safe accessors.
Localized strings
shared/src/commonMain/composeResources/
├── values/
│ └── strings.xml — default (English)
├── values-es/
│ └── strings.xml — Spanish
├── values-hi/
│ └── strings.xml — Hindi
└── values-ar/
└── strings.xml — Arabic
Uses Android's resource-qualifier system; works on every platform.
Platform differences to know
Back navigation
- Android has a hardware/system back button
- iOS has no back button — use a toolbar navigation button
- Desktop uses the window's close button + browser-style back
// commonMain — don't use Android's BackHandler here
@Composable
fun DetailScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Detail") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { /* ... */ }
}
// Android uses BackHandler in the Activity to connect system back
// iOS / Desktop rely on the UI button
Keyboard
Different keyboard semantics per platform. Use KeyboardOptions +
KeyboardActions — the same API works everywhere, Compose adapts.
Scroll
- Android uses finger scrolling
- iOS has momentum scrolling that Compose respects
- Desktop uses mouse wheel + drag
- Web uses wheel events
All handled by Compose's scroll machinery — your code is the same.
System UI
Notification channels, status bars, window insets differ. Bridge via
expect/actual:
// commonMain
expect fun setStatusBarColor(color: Color)
// androidMain — WindowCompat.getInsetsController(...)
// iosMain — no-op or read from app delegate
// desktop — no-op
Sharing UI state
Your shared ViewModel or Presenter drives the UI identically:
// commonMain
class ProductPresenter(
private val repository: ProductRepository,
scope: CoroutineScope
) {
val state: StateFlow<ProductState> = /* ... */
fun onRefresh() = scope.launch { repository.refresh() }
}
@Composable
fun ProductRoute(presenter: ProductPresenter) {
val state by presenter.state.collectAsState()
ProductScreen(state = state, onRefresh = presenter::onRefresh)
}
Works on every platform. No hiltViewModel() / ObservableObject per
platform.
Navigation on CMP
Navigation Compose (shared)
// commonMain
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha11")
@Composable
fun App() {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(/* ... */) }
composable("detail/{id}") { /* ... */ }
}
}
Type-safe routes (Kotlin serialization) also work, same as Android.
Decompose — alternative
Decompose is a popular alternative with stronger multiplatform ergonomics — stateful navigation that survives process death more gracefully.
ViewModels on CMP
Android's ViewModel is Android-only. For CMP:
- Use a shared presenter class scoped via DI (Koin)
- Or use
androidx.lifecycle:lifecycle-viewmodel-compose(on Compose 1.6+, works on CMP targets) for a cross-platformViewModel
// commonMain — cross-platform ViewModel
class ProductViewModel(
private val repository: ProductRepository
) : ViewModel() {
val state: StateFlow<ProductState> = /* ... */
override fun onCleared() {
super.onCleared()
// scope cancels automatically
}
}
Debugging and tooling
Android Studio
Works fully. Breakpoints in commonMain hit from any target.
Compose preview on CMP
@Preview
@Composable
fun GreetingPreview() {
MaterialTheme {
Greeting("Aarav")
}
}
- Android: works in the Android Studio preview pane
- iOS / Desktop: preview via the desktop run (less fluid but works)
Hot reload
Desktop supports hot reload via JetBrains Toolbox / IDEA. iOS doesn't yet — each change requires a rebuild. Expect iOS hot reload in future versions.
When CMP is the right choice
When CMP shines
- Design-system-heavy apps (one component library)
- Non-native-feeling apps (Material design on iOS is fine)
- Multi-platform B2B / internal tools
- Games or apps with custom UI (not Apple HIG)
- Teams with strong Compose skills
- Scenarios where Android + Desktop is primary
When to stay native
- iOS-first apps (iOS users expect HIG)
- Apps needing deep native iOS integration (SiriKit, WidgetKit)
- Flagship consumer apps where UX parity with native matters
- Small teams without iOS Xcode / Swift experience
- Web-first apps (Flutter Web / React are more mature)
- Teams unwilling to debug Kotlin/Native issues
Real-world adoption
As of 2025:
- Instacart, McDonald's, Philips, 9GAG, Toursprung ship CMP on iOS
- JetBrains Toolbox, Android Studio themselves use Compose Desktop
- Active development — versioning stabilizes quickly
Production-ready for:
- Android + Desktop — fully stable
- iOS — stable (1.6+)
- Web (Wasm) — alpha; promising but not production
Interop — when you need platform views
Compose Multiplatform lets you embed native views:
Android — AndroidView
Same as Jetpack Compose on Android. See Compose Interop.
iOS — UIKitView
// iosMain
@OptIn(ExperimentalForeignApi::class)
@Composable
fun AppleMapView(coordinate: Coordinate) {
UIKitView(
factory = {
val mapView = MKMapView()
val region = MKCoordinateRegionMakeWithDistance(
CLLocationCoordinate2DMake(coordinate.lat, coordinate.lng),
500.0, 500.0
)
mapView.setRegion(region, animated = false)
mapView
},
modifier = Modifier.fillMaxSize()
)
}
Desktop — SwingPanel
// desktopMain
SwingPanel(
factory = { JMyCustomPanel() },
modifier = Modifier.fillMaxSize()
)
Distribution
Android
Same as a regular Android app. Play Store + AAB.
iOS
Build the framework, integrate into Xcode project, upload via App Store Connect. TestFlight for beta.
Desktop
plugins {
id("org.jetbrains.compose")
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "My App"
packageVersion = "1.0.0"
}
}
}
./gradlew packageDmg — macOS installer. packageMsi for Windows,
packageDeb for Linux.
Web (Wasm)
./gradlew wasmJsBrowserDistribution
# Output: webApp/build/dist/wasmJs/productionExecutable/
Deploy to any static host. Modern browsers support Wasm; older ones require polyfills.
Common anti-patterns
CMP mistakes
- Android-specific APIs (BackHandler, Activity) in commonMain
- Hardcoded paths / resource IDs
- Ignoring iOS HIG for iOS-targeted apps
- No test coverage on iOS / Desktop (assuming it "just works")
- java.time.* / java.io.File in commonMain
- Using Jetpack Hilt in shared code
Modern CMP
- Platform-specific code in androidMain / iosMain only
- composeResources for shared assets
- Design a UX that works acceptably on all target platforms
- Run tests per target (./gradlew allTests)
- kotlinx-datetime, okio or platform expect/actual
- Koin / Kotlin-Inject for DI
Key takeaways
Practice exercises
- 01
Hello CMP
Bootstrap a shared module with Android + iOS + Desktop targets. Add an App() composable that shows a list of items; run on all three.
- 02
Shared resources
Move one string and one image into composeResources/. Access via Res.string.* and Res.drawable.* across all platforms.
- 03
Cross-platform navigation
Add Navigation Compose to your shared module. Wire up a 2-screen flow (list → detail) that works on every target.
- 04
Platform integration
Add an expect/actual for "showToast(message)". Implement via Toast on Android, UIAlertController on iOS, println on desktop.
- 05
Package a desktop app
Run ./gradlew packageDmg (or packageMsi / packageDeb). Install the resulting app and confirm it runs.
Next
Continue to Wear OS & Health Connect for wearable integration, or Android TV, Auto, BLE & XR for the full platform landscape.