Compose Multiplatform Fundamentals

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Compose Multiplatform (CMP) is JetBrains' declarative UI toolkit for Kotlin: you write @Composable functions once in commonMain and run them on Android, iOS, desktop (JVM) and the web. It is not a different framework from Jetpack Compose. It is the same compiler plugin, the same runtime and, for the most part, the same API surface, with the missing platforms filled in by JetBrains. That is why this page does not re-teach composables, state or modifiers — those are in Compose Fundamentals and Compose State and Side Effects in the Android section, and everything there applies unchanged. What is new here is everything around the composables: where the libraries come from, how the build is wired, how each platform starts the UI, and which Android-only habits break in common code.

The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 (com.android.kotlin.multiplatform.library for shared modules) and Xcode 26.4, with Ktor 3.6.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6, SQLDelight 2.4.0, Room 3 (androidx.room3) 3.0.3, Mokkery 3.5.0, vanniktech maven-publish 0.37.0, Dokka 2.2.0 and Turbine 1.2.1 in the neighbouring pages. Stability levels used below: CMP for Android, iOS and desktop is Stable; CMP for web (Kotlin/Wasm) is Beta; the Compose Hot Reload MCP server and the desktop window API v2 are Experimental; Swift export and SwiftPM import, which appear in the iOS integration pages, are Alpha. Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate app (package com.example.trailmate): the Gradle module sharedUI holds the screens, and androidApp, iosApp, desktopApp and webApp each contain only an entry point.

CMP and Jetpack Compose: Who Owns What

Jetpack Compose is Google’s toolkit for Android. Compose Multiplatform builds on code and releases published by Google: JetBrains takes each Jetpack Compose release as the base of the next CMP release, adds the platform support for iOS, desktop and web, stabilizes it, and ships. The two are developed in close collaboration, but they are separate products with separate artifacts and release dates.

Aspect Jetpack Compose Compose Multiplatform

Platforms

Android only

Android, iOS, desktop (macOS, Windows, Linux), web

Supported by

Google

JetBrains

Maven group of the libraries

androidx.compose.*

org.jetbrains.compose. for Skia targets, but Google’s androidx.compose. artifacts on Android

Compiler and runtime

Compose compiler (in the Kotlin repository since Kotlin 2.0) and Compose runtime

The same compiler plugin and the same runtime source

Rendering on Android

The Android View system and platform Canvas

The same — on Android CMP is Jetpack Compose

Rendering elsewhere

Not applicable

Skia, through Skiko (Kotlin bindings for Skia), into a native surface

Resources

Android resource system (R class)

Compose Multiplatform resources (Res class), see Resources and Localization in Compose Multiplatform

Maps, WebView, Hilt, CameraX

Available

Not in common code; Android-only (see the Android-only APIs section below)

Release cadence

Google’s Compose BOM releases

Follows Jetpack Compose with a gap of roughly one to three months

The practical consequence: you can learn Compose from almost any Jetpack Compose material — Google’s official documentation included — and apply it to commonMain. The differences are the platform edges, which is what this page maps. See the Android section’s Compose Layouts, Lists and Modifiers and Compose Material 3 and Theming for the shared vocabulary.

The Stack, Layer by Layer

Layered diagram of the Compose Multiplatform stack. At the top is the sharedUI module in commonMain with App()\

Reading the figure from the top:

  • Your code is ordinary Kotlin in commonMain. It never mentions a platform.

  • The compiler plugin transforms every @Composable function (adds the hidden Composer parameter, group keys, skipping logic). It ships inside the Kotlin compiler repository, so its version always equals the Kotlin version.

  • The runtime keeps the composition, the snapshot state system and recomposition. It is independent of any UI: it is the same engine that powers non-UI uses of Compose.

  • The UI libraries (ui, foundation, animation, material3, …​) are the part JetBrains republishes as multiplatform builds of Google’s sources.

  • The renderer is the part that differs. On Android, Compose draws through the platform. On iOS, desktop and web it draws itself with Skia — pixels, not native widgets. That single decision explains most of the platform differences later on this page.

Artifacts and Version Mapping

On Android, a CMP build pulls Google’s published artifacts; on every other target it pulls JetBrains' builds. You do not choose between them — the Gradle metadata of each CMP artifact maps androidx coordinates for the Android target. The CMP version is not the Kotlin version and not the Jetpack Compose version, though they correspond:

Compose Multiplatform Jetpack Compose used on Android Notes

1.12.1 (baseline)

1.12.1

Skia updated to Milestone 150; improved lazy-layout scrolling on iOS; automatic font fallback on web.

1.11.1

1.11.2

Previous feature release.

1.10.3

1.10.5

Multiplatform @Preview unified; Compose Hot Reload bundled.

1.9.3

1.9.4

Before the 1.10 deprecations.

The 1.12.1 release publishes the following artifact families. Note that material3 and the navigation and lifecycle libraries are versioned separately from the core Compose libraries, following Google’s own versioning of those libraries:

Library Maven coordinates (CMP 1.12.1) Version

Runtime

org.jetbrains.compose.runtime:runtime

1.12.1

UI

org.jetbrains.compose.ui:ui

1.12.1

Foundation

org.jetbrains.compose.foundation:foundation

1.12.1

Material 3

org.jetbrains.compose.material3:material3

1.12.0-alpha03 (own version line)

Lifecycle

org.jetbrains.androidx.lifecycle:lifecycle-*

2.11.0

Navigation

org.jetbrains.androidx.navigation:navigation-*

2.10.0-beta01

Check the exact versions on the release page before pinning them; they move with every CMP release. Material 3, lifecycle and navigation each get a page of their own: Lifecycle, ViewModel and Navigation in Compose Multiplatform and Layouts, Material 3, Adaptive UI, Popups and Accessibility.

Two rules of thumb keep versions sane:

  1. Kotlin and CMP do not need manual alignment. The latest CMP is always compatible with the latest Kotlin, and the Compose compiler plugin must simply carry the same version as the Kotlin plugin.

  2. Libraries built on CMP must be compiled against a compatible Kotlin. CMP 1.8 and later use the K2 compiler exclusively, so depend on CMP-based libraries only when they were compiled with Kotlin 2.1.0 or newer (and prefer 2.2.20 or newer for iOS and web, which evolve fastest).

Platforms, Minimum Versions and Stability

CMP supports only 64-bit platforms. The minimum versions for CMP 1.12.1 and the stability level of each target:

Platform Minimum version Stability Notes

Android

Android 5.0 (API level 21)

Stable

Same code path as Jetpack Compose.

iOS

iOS 14

Stable

Rendered with Skia (Metal) inside a UIViewController; interop with UIKit and SwiftUI, see Compose Multiplatform on iOS.

Desktop (macOS)

macOS 13 on arm64

Stable

JVM application; JDK 11 or later to run, JDK 17 or later to package.

Desktop (Windows)

Windows 10 (x86-64, arm64)

Stable

Same JVM application model.

Desktop (Linux)

Ubuntu 20.04 (x86-64, arm64)

Stable

Same JVM application model; see Compose Multiplatform for Desktop.

Web

Browsers with WasmGC support

Beta

Kotlin/Wasm; see Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.

"Beta" for web is a statement about API and behaviour stability, not about whether it runs: expect rough edges (font loading, accessibility, bundle size) and rely on the compatibility build described in the web page when you must support browsers without WasmGC. The matching Kotlin-side tiers are explained in How KMP Compiles: Targets and Tiers.

Wiring the sharedUI Module

A Compose module needs three things: the Kotlin Multiplatform plugin, the Compose compiler plugin and the CMP Gradle plugin. The first two follow the Kotlin version; the CMP plugin follows the CMP version. Declare them once in the version catalog:

# gradle/libs.versions.toml (excerpt)
[versions]
kotlin = "2.4.20"
agp = "9.4.0"
compose-multiplatform = "1.12.1"

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-application = { id = "com.android.application", version.ref = "agp" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Note that compose-compiler uses version.ref = "kotlin": that is the whole point of the compiler living in the Kotlin repository. Then apply them to every module that contains @Composable code — here sharedUI — and declare the libraries by direct coordinates:

// sharedUI/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.kotlin.multiplatform.library)
    alias(libs.plugins.compose.multiplatform)
    alias(libs.plugins.compose.compiler)
}

kotlin {
    android {
        namespace = "com.example.trailmate.shared.ui"
        compileSdk = 36
        minSdk = 24
    }
    iosArm64()
    iosSimulatorArm64()
    jvm("desktop")
    wasmJs { browser() }

    sourceSets {
        commonMain.dependencies {
            api(projects.sharedLogic)
            implementation(libs.compose.runtime)
            implementation(libs.compose.foundation)
            implementation(libs.compose.material3)
            implementation(libs.compose.ui)
            implementation(libs.compose.components.resources)
        }
    }
}

The module uses com.android.kotlin.multiplatform.library, so there is no androidTarget() call and no Android application in this module: MainActivity lives in androidApp, as explained in The Android Target and AGP 9. The matching catalog entries are plain library aliases:

# gradle/libs.versions.toml (excerpt, libraries)
[libraries]
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" }
# material3 has its own version line (1.12.0-alpha03 in the CMP 1.12.1 release notes)
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.12.0-alpha03" }

The <material3-version-from-release-notes> placeholder is deliberate: Material 3 is versioned independently, and a stale hard-coded value is a common source of "unresolved reference" errors after a CMP upgrade. The project layout around this module (which modules exist, who depends on whom) is described in Project Structure, Targets, Source Sets and the Hierarchy Template.

The Compose Compiler Plugin

The Compose compiler is what turns annotated functions into a recomposable tree. Since Kotlin 2.0.0 it is merged into the Kotlin repository and released together with Kotlin, so there is no longer a separate "compiler version vs Kotlin version" compatibility table — the artifact is the Gradle plugin org.jetbrains.kotlin.plugin.compose, versioned identically to Kotlin. Apply it in every module that contains composables, next to org.jetbrains.compose. When used with AGP, it overrides the compiler coordinates AGP would otherwise supply.

Compiler options live in the composeCompiler \{} extension. Reports are the most useful ones: they show which composables the compiler considers restartable and skippable, and which types it considers unstable.

// sharedUI/build.gradle.kts
composeCompiler {
    // Where to write the per-module reports (text) and metrics (CSV/JSON)
    reportsDestination.set(layout.buildDirectory.dir("compose_compiler"))
    metricsDestination.set(layout.buildDirectory.dir("compose_compiler"))

    // Tell the compiler that kotlinx.datetime and our domain model are stable
    stabilityConfigurationFiles.add(
        rootProject.layout.projectDirectory.file("stability_config.conf")
    )
}

Why this matters more in multiplatform than on Android only: a type from a library compiled without the Compose compiler is "unstable" by default, and a composable that takes it is re-run on every parent recomposition. The stability configuration file (one class pattern per line, for example com.example.trailmate.domain. and kotlinx.datetime.) is the supported way to opt such types in without wrapping them. Reading and acting on the reports is covered in the Android section’s Debugging, Code Quality and Performance.

Two Kotlin 2.0 era issues are worth knowing by name, because they show up in old posts. Compose compiler 2.0.0 sometimes inferred stability incorrectly on non-JVM targets, which caused needless or endless recompositions; the fix is Kotlin 2.0.10 or later and dependencies built with the same compiler. Switching a project between Kotlin 1.9 and 2.0 can also trigger MissingResourceException: Missing resource with path until all build/ directories are deleted.

Deprecated Plugin Aliases and Direct Coordinates

Older CMP projects declared UI dependencies through accessors generated by the Gradle plugin: compose.runtime, compose.ui, compose.foundation, compose.material3, compose.components.resources. Those accessors are deprecated; modern projects, including the wizard-generated ones, depend on the artifact coordinates directly, usually through the version catalog as above.

Legacy accessor (deprecated) Direct coordinate

compose.runtime

org.jetbrains.compose.runtime:runtime

compose.ui

org.jetbrains.compose.ui:ui

compose.foundation

org.jetbrains.compose.foundation:foundation

compose.material3

org.jetbrains.compose.material3:material3

compose.components.resources

org.jetbrains.compose.components:components-resources

compose.uiTooling / compose.preview

org.jetbrains.compose.ui:ui-tooling / org.jetbrains.compose.ui:ui-tooling-preview

Why the change: the accessors forced every dependency to the plugin’s own version, while Material 3, navigation and lifecycle now follow independent version lines. Direct coordinates let the version catalog (and Gradle’s dependency management) own all versions, and they make the dependency list readable by any Gradle tool. A project that still uses compose.* builds with deprecation warnings; migrate them when you touch the build.

Writing Common UI

With the module wired, the UI is plain Compose in commonMain. TrailMate’s root composable takes only what it needs (no platform types) and delegates to a theme and a screen:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/App.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.example.trailmate.presentation.TrailListViewModel
import org.koin.compose.viewmodel.koinViewModel

@Composable
@Preview
fun App() {
    TrailMateTheme {
        val viewModel: TrailListViewModel = koinViewModel()
        TrailListScreen(viewModel = viewModel)
    }
}

App() hands the screen to TrailListScreen, a plain composable in the same package that collects the shared ViewModel’s StateFlow<TrailListUiState> and renders a LazyColumn. Every Compose import in such code comes from androidx.compose. packages — *the package names are the same as in Jetpack Compose, even though on iOS, desktop and web the artifacts are JetBrains builds. That is what makes code written for Android compile unchanged. safeContentPadding() matters on iOS: it keeps content out of the notch and the home-indicator area, which Android draws edge to edge by default. ViewModel creation (koinViewModel()) is covered in Dependency Injection with Koin and Metro and Architecture and Shared ViewModels.

Entry Points: One Per Platform

The shared App() is never the program’s entry point. Every platform has its own bootstrap, and these thin entry points are where platform-specific wiring (dependency injection, edge-to-edge, window size) belongs. In a well-structured project they are the only files that differ.

Platform Entry point What hosts the composition

Android

MainActivity calls setContent \{ App() }

ComponentActivity → AndroidComposeView

iOS

ComposeUIViewController \{ App() }, wrapped by SwiftUI or UIKit

a UIViewController with a Metal-backed view

Desktop

application \{ Window(…​) \{ App() } }

a Swing/AWT window on the JVM

Web

ComposeViewport \{ App() }

a canvas inside the page, sized to the viewport

Android: MainActivity and setContent

// androidApp/src/main/kotlin/com/example/trailmate/android/MainActivity.kt
package com.example.trailmate.android

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.trailmate.ui.App

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContent { App() }
    }
}

This is exactly the Jetpack Compose entry point; the Android basics are in Activities and Lifecycle. The androidApp module depends on sharedUI (implementation(projects.sharedUI)) and applies com.android.application; Koin is started from the Application class.

iOS: ComposeUIViewController

On iOS, Kotlin exposes a function that returns a UIViewController; Swift hosts it. The Kotlin side:

// sharedUI/src/iosMain/kotlin/com/example/trailmate/ui/MainViewController.kt
package com.example.trailmate.ui

import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController

fun MainViewController(): UIViewController = ComposeUIViewController { App() }

The Swift side wraps that controller in a UIViewControllerRepresentable and ignores the safe area so Compose can draw to the screen edges (it applies insets itself):

// iosApp/iosApp/ContentView.swift
import SwiftUI
import Shared   // the framework produced by sharedUI

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        MainViewControllerKt.MainViewController()
    }
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

struct ContentView: View {
    var body: some View {
        ComposeView()
            .ignoresSafeArea()   // Compose handles the safe area itself
    }
}

ComposeUIViewController also takes a configure lambda for options such as the status-bar style and accessibility behaviour; the nesting patterns (Compose inside SwiftUI, SwiftUI inside Compose, UIKit views) are in Compose Multiplatform on iOS. The Xcode wiring of the framework is in Integrating Shared Code into iOS, and the SwiftUI side in SwiftUI Views and Modifiers.

Desktop: application and Window

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/Main.kt
package com.example.trailmate.desktop

import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.example.trailmate.ui.App

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "TrailMate",
        state = rememberWindowState(size = DpSize(480.dp, 800.dp)),
    ) {
        App()
    }
}

application \{} starts the event loop; Window creates a native window; closing it calls exitApplication so the process ends. Multiple windows, trays and menus are the topic of Compose Multiplatform for Desktop.

Web: ComposeViewport

// webApp/src/wasmJsMain/kotlin/com/example/trailmate/web/Main.kt
package com.example.trailmate.web

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import com.example.trailmate.ui.App

// The web API is experimental and requires an opt-in
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    ComposeViewport { App() }
}

ComposeViewport attaches the composition to the page body (an overload accepts a container element) and resizes it with the browser viewport. The @OptIn(ExperimentalComposeUiApi::class) is part of why the web target is Beta. The page’s HTML, the compatibility build and browser navigation are covered in Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.

Common Previews

Previews render a composable in the IDE (IntelliJ IDEA and Android Studio) without launching an emulator. They are a core Jetpack Compose feature, and CMP now uses the same annotation: since CMP 1.10.0 the androidx.compose.ui.tooling.preview.Preview annotation is multiplatform. The earlier JetBrains-specific annotation (org.jetbrains.compose.ui.tooling.preview.Preview) is deprecated; use the androidx one in new code. Previews need an Android target, because the rendering is done by the Android tooling.

// sharedUI/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            // The multiplatform @Preview annotation (CMP 1.10.0 and later)
            implementation("org.jetbrains.compose.ui:ui-tooling-preview:1.12.1")
        }
    }
}

dependencies {
    // com.android.kotlin.multiplatform.library has no debugImplementation;
    // androidRuntimeClasspath puts the tooling on the preview classpath only
    androidRuntimeClasspath("org.jetbrains.compose.ui:ui-tooling:1.12.1")
}

With AGP 9’s Android-KMP library plugin the tooling dependency goes into androidRuntimeClasspath, as in the snippet (if your IDE’s Gradle accessors do not generate it, write it as "androidRuntimeClasspath"(…​)); with com.android.application — for example in androidApp — the traditional debugImplementation is used instead. A preview is an ordinary composable annotated with @Preview, like App() above.

The supported @Preview parameters in common code are name, group, widthDp, heightDp, locale, showBackground and backgroundColor; the Android-only ones (device frames, UI mode) are Android tooling features. The Jetpack Compose side of previews is in Android Developers — Preview your UI with composable previews.

Compose Hot Reload

Previews show isolated composables with fake data. Compose Hot Reload instead applies your code changes to a running app, without restarting it and with its state preserved. It works on the desktop JVM target; there is no hot reload on iOS, Android-via-CMP or web, so the desktop app is your fast UI sandbox for common code, and you verify on the real platforms afterwards.

  • Since CMP 1.10.0 the Compose Hot Reload Gradle plugin is bundled and enabled by default in every project with a desktop target.

  • It requires Kotlin 2.1.20 or later and runs on the JetBrains Runtime (JBR), an OpenJDK fork with enhanced class redefinition. The newest JBR supports Java 21; a project that is only Java 22+ compatible fails with a linkage error, so use a JDK 21 toolchain for the desktop module. The Foojay toolchain resolver plugin can provision JBR automatically.

  • From the terminal, run the desktop app with hot reload through the hotRun task; from the IDE, use the gutter action Run with Compose Hot Reload (or the generated desktopApp [hot] run configuration). Saving the file, or pressing the reload shortcut, applies the change.

  • CMP 1.12.0 added a built-in MCP server for AI coding agents (trigger a reload, take screenshots, read the semantics tree, simulate input). It is Experimental.

# repository root -- run TrailMate Desktop with hot reload
./gradlew :desktopApp:hotRun --mainClass com.example.trailmate.desktop.MainKt

The comparison of the three feedback loops:

Tool What it shows Targets Needs

@Preview

An isolated composable with test data

Rendered by Android tooling, from common code

An Android target and the tooling dependencies

Compose Hot Reload

The whole running app, updated in place

Desktop JVM only

JBR, JDK 21 toolchain, a desktop target

Full run

The real app

Any target

Emulator, simulator, browser or desktop launch

Android-Only APIs to Keep Out of commonMain

Jetpack Compose documentation and community samples are written for Android. If you paste a snippet into commonMain and the IDE reports an unresolved reference, it is almost always an Android-only API. There are three categories:

  1. Android-specific by nature — they use android.* classes and make no sense elsewhere.

  2. Multiplatform replacements exist under a different name or library.

  3. Not yet ported — they may arrive in a later CMP release.

Android-only habit Why it does not compile in common code Common-code replacement

LocalContext.current, Context

android.content.Context does not exist off Android

Pass what you need from the entry point, use expect/actual (see below) or Koin; resources through Res

Activity.setContent, ComposeView, LocalView

They are Android view-system classes

The per-platform entry points above

BitmapFactory, Bitmap, asAndroidBitmap()

android.graphics types

ImageBitmap, painterResource, Res.readBytes(…​) with decodeToImageBitmap()

stringResource(R.string.x), R class

Android resource IDs

stringResource(Res.string.x) from the generated Res class, see Resources and Localization in Compose Multiplatform

Modifier.pointerInteropFilter

Wraps Android MotionEvent

Modifier.pointerInput

Hilt (hiltViewModel())

Annotation processing on Android types

Koin (koinViewModel()) or manual factories, see Dependency Injection with Koin and Metro

Maps Compose

Google Maps SDK is Android-only

Platform map views through interop (for example MKMapView on iOS)

WebView, CameraX, permissions, Bluetooth, GPS

Out of CMP’s scope: platform services, not UI

A shared interface plus platform implementations, e.g. LocationProvider

A few items in the "not yet ported" category exist even without android classes in their signatures, such as Modifier.imeNestedScroll(), Modifier.systemGestureExclusion(), Modifier.magnifier(), LocalOverscrollConfiguration and AnimatedImageVector.animatedVectorResource. The documentation lists them per release; re-check the list after every upgrade, because items move out of it.

When a platform capability is genuinely needed, do not leak the Android type into common code. Define an interface or an expect declaration and provide the platform parts in platform source sets — the mechanism is explained in Expect/Actual and Platform APIs:

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/PlatformInsets.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable

/** Opens the platform's share sheet. Common code never touches android.content.Context. */
interface ShareLauncher {
    fun shareTrail(name: String, url: String)
}

@Composable
expect fun rememberShareLauncher(): ShareLauncher
// sharedUI/src/androidMain/kotlin/com/example/trailmate/ui/PlatformInsets.android.kt
package com.example.trailmate.ui

import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext

@Composable
actual fun rememberShareLauncher(): ShareLauncher {
    // LocalContext is legal here: this is androidMain, not commonMain
    val context = LocalContext.current
    return remember(context) {
        object : ShareLauncher {
            override fun shareTrail(name: String, url: String) {
                val send = Intent(Intent.ACTION_SEND).apply {
                    type = "text/plain"
                    putExtra(Intent.EXTRA_TEXT, "$name $url")
                }
                context.startActivity(Intent.createChooser(send, null))
            }
        }
    }
}

The composable’s signature is common; each platform fills in its own implementation. The desktop and web source sets would provide actual`s too (a desktop clipboard copy, the browser share API), or the function can return a no-op. The alternative is a plain interface supplied by Koin from each entry point; choose `expect when the implementation needs @Composable context such as LocalContext.

Platform-Specific Default Behaviours

CMP aims for the same behaviour everywhere, but a handful of differences are unavoidable. They are not bugs; plan for them and test on each platform:

Area What differs What to do

Fonts and text

Without an explicit font each system uses its own default font, and anti-aliasing differs even for the same font. No pixel-perfect match is guaranteed.

Bundle a font (see the resources page) if brand typography or screenshot tests matter.

Software keyboard and insets

iOS imitates the Compose window-insets approach, but the keyboard can be positioned slightly differently.

Use WindowInsets.ime and safeContentPadding(); verify the keyboard never covers fields on both platforms.

Touch and mouse

Desktop interprets all pointer input as mouse gestures; there is no multi-touch (no pinch-to-zoom).

Offer buttons or the scroll wheel as alternatives on desktop.

Scroll physics

Android and iOS match their platform feel; desktop scrolling is limited to the mouse wheel.

Nothing — do not emulate platform physics by hand.

Native pop-ups

Text-selection actions (Copy, Translate) are the platform’s own and look different per platform.

Accept it; they are not customizable from Compose.

Back gesture

Android has it by default. iOS has none by default, but CMP implements an edge-swipe back to match Android. On desktop the Esc key is the back trigger.

Wire back handling through the navigation APIs, see Lifecycle, ViewModel and Navigation.

Interop views

Embedding native views needs platform-specific APIs: UIKit and SwiftUI interop on iOS, Swing interop on desktop.

See the iOS and desktop pages of this section.

The rendering choice is also the source of the biggest non-difference: because every Skia target draws its own pixels, the UI looks identical on iOS and desktop — it does not adopt iOS or macOS system controls. A Switch on iOS is Material’s switch, not a UISwitch. If you need genuinely native look and feel, either use native UI with shared logic (see What Is Kotlin Multiplatform) or embed native views, as TrailMate does for the map.

Common Mistakes

  • Mixing Compose versions across modules. The compiler plugin must match the Kotlin version, and the org.jetbrains.compose plugin version should be identical in every module. A mismatch shows up as linker errors on iOS or NoSuchMethodError on the JVM.

  • Forgetting the compiler plugin in one module. A module with @Composable code but without org.jetbrains.kotlin.plugin.compose fails to compile or, worse, behaves incorrectly at runtime. Apply it to every Compose module.

  • Using debugImplementation in a library module with the Android-KMP plugin. There are no build variants; use androidRuntimeClasspath for tooling.

  • Running Hot Reload on a non-JBR JDK, or on Java 22+. Pin a JDK 21 toolchain for the desktop module.

  • Leaking LocalContext into common code. Use expect/actual or an interface injected by Koin.

  • Expecting native widgets. On Skia targets controls are drawn, not wrapped; native look needs interop.

  • Ignoring the iOS safe area. Compose draws edge to edge; use safeContentPadding() and WindowInsets rather than hard-coded top padding.