Use 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).
npx skills add https://github.com/ericrisco/rsc-harness --skill compose-multiplatform
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.
Pin these or the K2 compiler bites you. Verify against current docs before locking a project — these are the floors, not opinions.
jpackage packaging).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.
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 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:
expect needs an actual in every compiled target. An orphan expect is not a warning — it is a hard build failure (often only surfacing on the iOS target), so add the actual per target or remove the target.expect symbol multiplies into N actuals you maintain; expose the smallest function, not a fat class.interface + DI over deep expect trees for anything you want to test or fake. expect class can'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.
You bridge in both directions. Shared Compose embeds native views; native hosts embed shared Compose.
UIKitView / UIKitViewController with a factory lambda.ComposeUIViewController in a UIViewControllerRepresentable.AndroidView for native views; host the tree via setContent { App() } in an Activity.application { Window { App() } }; Swing interop via SwingPanel.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.
androidx.lifecycle.ViewModel works in commonMain. Obtain instances with koin-compose-viewmodel's koinViewModel { } so they survive recomposition. iOS has no built-in ViewModelStoreOwner — tie the VM lifecycle to SwiftUI manually (KMP-ObservableViewModel lets SwiftUI observe Kotlin VMs).initKoin() and call it from the Android Application and from iOS app init:// commonMain
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
config?.invoke(this)
modules(appModule, platformModule)
}
androidx.navigation provides type-safe nav + deep links in commonMain.compose.components.resources generates Res accessors — Res.string.app_name, Res.drawable.logo, fonts — shared across all platforms.androidApp run config (hosts via setContent).iosApp in Xcode, or use the KMP iOS run config in the IDE../gradlew :desktopApp:run; package with ./gradlew :desktopApp:packageDistributionForCurrentOS (needs JDK 17+ for jpackage)../gradlew :webApp:wasmJsBrowserDevelopmentRun.| 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 |
After scaffolding or editing, run scripts/verify.sh <project-dir> (read-only, no Gradle/Xcode needed). It statically checks the structural invariants:
commonMain source set exists;expect in commonMain has a matching actual in some platform source set (catches orphans);org.jetbrains.compose) and a Kotlin version are present, and Kotlin is >= 2.1.0 (K2 floor);commonMain.It exits 0 on a clean or empty target and non-zero only on hard failures.
Take ericrisco/compose-multiplatform from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.