Compose Multiplatform
SkillAI & modelsUse when building one shared Compose UI in Kotlin across Android, iOS, and desktop — commonMain @Composables, expect/actual, source-set placement, native interop, multiplatform ViewModel/navigation/Koin. NOT a single-platform native build (that is kotlin-android / swift-ios), and NOT Dart/Flutter cross-platform UI (that is flutter).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Compose Multiplatform skill
What this skill tells your AI
The instructions your AI receives, as published by ericrisco/rsc-harness in skills/compose-multiplatform/SKILL.md and read by ahel’s review.
You write one Compose UI tree in commonMain and let each platform be a thin host. The whole discipline is one sentence: common by default, platform by exception. Every line you put in commonMain ships to Android, iOS, and desktop unchanged; every line you put in a platform source set is a deliberate exception you should be able to justify.
Versions floor (2026)
Pin these or the K2 compiler bites you. Verify against current docs before locking a project — these are the floors, not opinions.
- Compose Multiplatform 1.11.0, bundling Jetpack Compose 1.11.1.
- Kotlin 2.1.0+ required (2.2.20 recommended for evolving iOS/Web targets). Since CMP 1.8.0 the K2 compiler is mandatory, so every dependency must compile against Kotlin 2.1.0+.
- iOS is Stable (production-ready since CMP 1.8.0, May 2025): feature parity for popular cases, type-safe navigation with deep linking, accessibility (VoiceOver, Full Keyboard Access).
- Web is Beta (CMP 1.9.0, Sept 2025), runs on WasmGC browsers. Do not promise Web parity — ship Android/iOS/desktop, pilot Web.
- Platform minimums: Android API 21, iOS 14+, macOS 13 arm64, Windows 10+, Ubuntu 20.04+, desktop JDK 11+ (17+ for
jpackagepackaging).
Where does this code go?
This is the question you answer dozens of times a day. Default to the leftmost column that compiles.
| Source set | Put here | Concrete example | Never here |
|---|---|---|---|
commonMain | Shared @Composables, ViewModels, business logic, common interfaces, expect declarations | @Composable fun GreetingScreen(), expect fun platformName(): String | android.*, platform.UIKit, java.awt, androidx.activity |
androidMain | Activity, actual using Android Context/Build | class MainActivity : ComponentActivity | iOS/desktop-only APIs |
iosMain | ComposeUIViewController factory, actual via cinterop/platform.* | fun MainViewController() = ComposeUIViewController { App() } | android.* |
desktopMain | application {} window, Swing interop | application { Window(::exitApplication) { App() } } | mobile-only APIs |
wasmJsMain (Beta) | Web entry point | ComposeViewport(document.body!!) { App() } | anything you can't ship as Beta |
Why this matters: a platform import in commonMain breaks the build for every other target, and the error surfaces in the iOS link step, far from the offending line. Keep commonMain import-clean.
Project structure (the 2026 default)
The current default KMP layout (announced May 2026, aligned with AGP 9.0) is a dedicated shared KMP library module + per-platform app modules, not the old single composeApp:
my-app/
shared/ # KMP library: commonMain holds the Compose UI tree
src/
commonMain/ # @Composables, ViewModels, expect declarations, DI
androidMain/ # actual impls using android.*
iosMain/ # actual impls + ComposeUIViewController
desktopMain/ # actual impls + application {} window
wasmJsMain/ # web entry (Beta)
androidApp/ # thin Android host -> setContent { App() }
iosApp/ # Xcode project -> embeds the shared framework
desktopApp/ # ./gradlew :desktopApp:run
webApp/ # WasmGC entry (Beta)
Split rule: if some screens are native and only some are shared Compose, split into sharedLogic (all platforms) + sharedUI (CMP platforms only). A server-inclusive project adds a root core module. Don't pre-split — start with one shared module and split when a platform genuinely needs native UI.
Source-set hierarchy — commonMain fans out, with intermediate sets where targets share code:
commonMain
├── androidMain
├── desktopMain (jvm)
├── wasmJsMain (Beta)
└── iosMain (intermediate)
├── iosArm64
└── iosSimulatorArm64
Scaffold a new project with kmp.new or the Kotlin Multiplatform wizard (IntelliJ IDEA 2025.2.2+ / Android Studio Otter 2025.2.1+ with the KMP plugin). Add a shared module to an existing Android app via Android Studio's Shared Module Template.
Minimal version-catalog plugin wiring (full Gradle in references/project-setup.md):
// gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
compose = "1.11.0"
agp = "9.0.0"
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
expect / actual — the core mechanism
expect/actual is how you reach a platform API while keeping the call site common. Declare expect in commonMain; provide an actual in every target you compile.
// commonMain
expect fun platformName(): String
// androidMain
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
import platform.UIKit.UIDevice
actual fun platformName(): String =
UIDevice.currentDevice.systemName + " " + UIDevice.currentDevice.systemVersion
Rules, each with the reason it exists:
- Every
expectneeds anactualin every compiled target. An orphanexpectis not a warning — it is a hard build failure (often only surfacing on the iOS target), so add theactualper target or remove the target. - Keep the common surface tiny. Each
expectsymbol multiplies into Nactuals you maintain; expose the smallest function, not a fat class. - Prefer a common
interface+ DI over deepexpecttrees for anything you want to test or fake.expect classcan't be mocked in common tests.
// Bad: deep expect class — N actuals, untestable in commonTest
expect class Database {
fun query(sql: String): List<Row>
fun close()
}
// Good: common interface, platform impls injected via Koin (fakeable in tests)
interface Database {
fun query(sql: String): List<Row>
fun close()
}
// androidMain/iosMain provide SqliteDatabase implementing Database, bound in a Koin module.
Native interop
You bridge in both directions. Shared Compose embeds native views; native hosts embed shared Compose.
- iOS — native view inside shared Compose:
UIKitView/UIKitViewControllerwith a factory lambda. - iOS — shared Compose inside SwiftUI: wrap
ComposeUIViewControllerin aUIViewControllerRepresentable. - Android:
AndroidViewfor native views; host the tree viasetContent { App() }in anActivity. - Desktop:
application { Window { App() } }; Swing interop viaSwingPanel.
Embed a native view through an injected interface, not a raw expect — so the common screen stays platform-agnostic and testable:
// commonMain
interface MapFactory { /* returns a platform map handle */ }
@Composable
fun MapScreen(mapFactory: MapFactory = koinInject()) {
// iosMain provides the actual UIKitView wiring around mapFactory; see references/ios-interop.md
}
Full bridge patterns (ComposeUIViewController SwiftUI wrapper, native-view-factory-via-Koin, MapKit/camera, ViewModel lifecycle) live in references/ios-interop.md — read it before writing iOS interop.
State, ViewModel, navigation, DI
androidx.lifecycle.ViewModelworks incommonMain. Obtain instances withkoin-compose-viewmodel'skoinViewModel { }so they survive recomposition. iOS has no built-inViewModelStoreOwner— tie the VM lifecycle to SwiftUI manually (KMP-ObservableViewModel lets SwiftUI observe Kotlin VMs).- Koin is the common DI runtime. Define a shared
initKoin()and call it from the AndroidApplicationand from iOS app init:
// commonMain
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
config?.invoke(this)
modules(appModule, platformModule)
}
- Navigation:
androidx.navigationprovides type-safe nav + deep links incommonMain. - Resources:
compose.components.resourcesgeneratesResaccessors —Res.string.app_name,Res.drawable.logo, fonts — shared across all platforms.
Running & packaging
- Android: run the
androidApprun config (hosts viasetContent). - iOS: open
iosAppin Xcode, or use the KMP iOS run config in the IDE. - Desktop:
./gradlew :desktopApp:run; package with./gradlew :desktopApp:packageDistributionForCurrentOS(needs JDK 17+ forjpackage). - Web (Beta):
./gradlew :webApp:wasmJsBrowserDevelopmentRun.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
android.* / platform.UIKit / java.awt import in commonMain | Breaks the build for every other target, error surfaces far away | expect/actual or inject via a common interface |
expect with no actual for a target | Hard build failure on that target | Add an actual per compiled target or drop the target |
Recreating a ViewModel each recomposition (remember { VM() } wrong) | State loss on every recompose | koinViewModel { } / hoist state |
| Treating Compose Web as production | Web is Beta (1.9), not Stable | Ship Android/iOS/desktop; pilot Web only |
| Kotlin < 2.1.0 with CMP 1.8+ | K2 incompatibility — deps fail to link | Bump to Kotlin 2.2.x |
Deep expect class for testable logic | Can't fake in commonTest | Common interface + Koin-injected platform impl |
Pre-splitting into sharedLogic/sharedUI on day one | Premature complexity, extra Gradle wiring | Start with one shared module; split when a platform needs native UI |
Verify
After scaffolding or editing, run scripts/verify.sh <project-dir> (read-only, no Gradle/Xcode needed). It statically checks the structural invariants:
- a
commonMainsource set exists; - every
expectincommonMainhas a matchingactualin some platform source set (catches orphans); - the Compose Multiplatform plugin (
org.jetbrains.compose) and a Kotlin version are present, and Kotlin is >= 2.1.0 (K2 floor); - no forbidden platform imports leak into
commonMain.
It exits 0 on a clean or empty target and non-zero only on hard failures.
Signals
- GitHub stars
- 82
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
compose-multiplatform-ericrisco- Source
- github.com/ericrisco/rsc-harness