← Back to list

Compose Multiplatform (CMP): One UI Codebase for Android, iOS, Desktop, and Web

A practical guide to JetBrains’ declarative UI framework from your first @Composable to advanced animations and platform-specific interop.

John safwat · 2026-04-24 22:03 · 0 claps · 8.7 min read
#android #compo #kotlin-multiplatform #mobile-app-development #compose-multiplatform
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🎬 · Film & Television

Compose Multiplatform (CMP): One UI Codebase for Android, iOS, Desktop, and Web

A practical guide to JetBrains’ declarative UI framework from your first @Composable to advanced animations and platform-specific interop.

For years, cross-platform UI development has been a series of trade-offs. You either embraced the compromise of a web view wrapped in native shell (hello, Cordova), accepted a JavaScript bridge and its performance ceiling (React Native), or built everything twice and prayed your designers didn’t ask for “just one more small change.”

Then JetBrains did something quietly radical. They took Google’s modern Android UI toolkit, Jetpack Compose, and asked: what if this ran everywhere?

That’s Compose Multiplatform (CMP) and in 2026, it’s arguably the most pragmatic way to ship a truly native UI from a single Kotlin codebase. Let’s dig in.

1. Introduction to Compose Multiplatform

What is CMP?

Compose Multiplatform is a declarative UI framework built and maintained by JetBrains, layered directly on top of Google’s Jetpack Compose for Android. If you’ve written Jetpack Compose, you already know roughly 90% of CMP. The same @Composable functions, the same Modifier chains, the same state model — just extended to run on iOS, Desktop (JVM), and Web (Wasm/JS).

Think of it this way: Jetpack Compose is the engine. CMP is the adapter kit that lets that engine drive every platform you care about.

The Problem It Solves

Kotlin Multiplatform (KMP) has been a hit among teams who wanted to share business logic — networking, database access, domain layers, use cases — across Android and iOS. But the UI layer? That was still two codebases: Jetpack Compose on Android, SwiftUI or UIKit on iOS, plus whatever you were doing for desktop and web.

CMP closes that gap. You can now share the entire UI layer across:

  • Android (via Jetpack Compose — nothing exotic, it’s the same stack)
  • iOS (rendered to a UIViewController you embed in your iOS app)
  • Desktop (macOS, Windows, Linux — runs on the JVM)
  • Web (Wasm is the current flagship target; JS/Canvas is also supported)

For many apps, this takes UI code reuse from ~0% to well over 90%.

“But Isn’t This Just a Web View?”

No. This is the most important misconception to squash up front.

CMP is not a JavaScript bridge. It is not a web view wrapper. It is not interpreting JSX at runtime.

  • On Android, CMP uses the native Jetpack Compose engine. It draws directly to the Android rendering pipeline.
  • On iOS and Desktop, CMP uses Skia — the same hardware-accelerated 2D graphics library that powers Chrome, Flutter, and Android itself. On iOS specifically, Skia is wired into Metal, Apple’s low-level GPU API.
  • On Web (Wasm), it renders via Skia to an HTML canvas, again with hardware acceleration.

This is native performance with a shared codebase. Scrolling is smooth. Animations hit 60 (or 120) fps. There is no JavaScript event loop between your code and the pixels.

2. Core Concepts: The Declarative Paradigm

Before touching code, you need a mindset shift — especially if you’re coming from UIKit, XML-based Android views, or even older web frameworks.

State-Driven UI

In imperative UI, you tell the framework what to do: textView.setText("Hello"), button.isEnabled = false, view.backgroundColor = .red. You're reaching into the UI tree and mutating things.

In CMP, the UI is a function of state. You never call textView.setText(). You update a variable, and the framework redraws whatever needs redrawing.

@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

If name changes, Greeting re-runs and the new text appears. No manual updates. No stale references. No "I forgot to call reloadData()."

Composition and Recomposition

When your app starts, CMP walks your @Composable functions and builds a tree of UI nodes. This first pass is called the Composition.

When state changes, CMP doesn't tear down and rebuild the whole tree. It performs Recomposition — surgically re-running only the composables whose inputs changed, and updating only the nodes affected. This is why Compose feels fast even when the UI is complex: the framework is doing the minimum work required.

Understanding this is what separates "Compose tourists" from "Compose residents." If you write composables that recompose on every keystroke when only one text field changed, you'll ship a laggy app. Keep state narrow. Pass only what each composable needs.

The @Composable Annotation

Every UI-producing function in CMP is annotated with @Composable. This tells the compiler plugin: this function doesn't return a value — it emits UI into the current composition.

@Composable
fun WelcomeScreen() {
    Text("Welcome!")
    Button(onClick = { /* ... */ }) {
        Text("Get started")
    }
}

@Composable functions can only be called from other @Composable functions. The compiler enforces this. It's not magic — it's a very disciplined form of metaprogramming.

3. How to Build a UI in CMP

Let’s move from theory to screen.

Basic Components

CMP ships with a rich set of foundational and Material components. The ones you’ll use constantly:

Text("A simple label")

Button(onClick = { println("clicked") }) {
    Text("Tap me")
}

Image(
    painter = painterResource("icon.png"),
    contentDescription = "App logo"
)

Icon(
    imageVector = Icons.Default.Favorite,
    contentDescription = "Favorite"
)

No XML, no storyboards, no Interface Builder. UI is code — typed, refactorable, and diffable in your PRs.

Layout Systems

CMP’s layout primitives are small in number and large in expressiveness:

  • **Column** — stacks children vertically.
  • **Row** — lays them out horizontally.
  • **Box** — places children on top of each other (z-stack).
  • **LazyColumn / LazyRow** — efficient scrolling lists. Only the visible items are composed. This is the CMP equivalent of Android's RecyclerView or iOS's UITableView / UICollectionView.
LazyColumn {
    items(messages) { message ->
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Avatar(message.sender)
            Column {
                Text(message.sender.name, style = MaterialTheme.typography.titleSmall)
                Text(message.body)
            }
        }
    }
}

If you’ve fought with RecyclerView adapters or UITableViewDataSource protocols, the LazyColumn block above will feel like cheating.

Modifiers: The Secret Sauce

Modifiers are how you style, size, position, and attach behavior to any composable. They chain left-to-right, and order matters.

Box(
    modifier = Modifier
        .size(120.dp)
        .background(Color.Blue, shape = RoundedCornerShape(16.dp))
        .clickable { println("tapped the box") }
        .padding(16.dp)
        .shadow(elevation = 8.dp)
)

Padding before background draws padding inside the background. Padding after background draws padding around it. That’s the mental model.

Modifiers handle: sizing, padding, margins, borders, shadows, click handlers, focus, drag gestures, semantics for accessibility, and much more. Most of your styling work happens here.

State Management

State in CMP is a first-class citizen. The two APIs you’ll use every day:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Two things happening here:

  • **mutableStateOf** creates an observable state holder. When its value changes, anything reading it recomposes.
  • **remember** tells Compose to keep this value across recompositions. Without it, you'd create a fresh 0 on every re-run and the counter would never increment.

For state that needs to survive configuration changes (rotations on Android, window resizes on desktop), use rememberSaveable. For state hoisted up to a ViewModel or shared across screens, combine CMP's state APIs with your favorite state container — MVVM, MVI, or a KMP-friendly library like decompose or voyager.

4. Animation in CMP

Animations in Compose are one of the framework’s quiet superpowers. Because they’re state-driven like the rest of the UI, they’re composable in every sense of the word. Let’s walk up the complexity ladder.

High-Level “Drop-In” Animations

Start here. Ninety percent of product animation needs are covered by three APIs.

**AnimatedVisibility** — animate a composable in or out:

AnimatedVisibility(
    visible = isExpanded,
    enter = fadeIn() + slideInVertically(),
    exit = fadeOut() + slideOutVertically()
) {
    DetailsPanel()
}

Flip isExpanded, and the panel fades and slides on its way in and out. No animation bookkeeping required.

**animateContentSize()** — smoothly animate a container when its contents grow or shrink:

Column(modifier = Modifier.animateContentSize()) {
    Text(headline)
    if (expanded) {
        Text(longDescription)
    }
}

Toggle expanded and the column smoothly stretches to accommodate the new content. No layout flicker, no manual height calculations.

**AnimatedContent** — crossfade between entirely different composables based on state:

AnimatedContent(targetState = screenState) { state ->
    when (state) {
        is Loading -> LoadingSpinner()
        is Success -> ResultView(state.data)
        is Error -> ErrorMessage(state.message)
    }
}

Property-Based Animations: animate*AsState

When you want to animate a single value smoothly, the animate*AsState family has you covered:

val color by animateColorAsState(
    targetValue = if (isActive) Color.Green else Color.Gray
)

val elevation by animateDpAsState(
    targetValue = if (isPressed) 2.dp else 8.dp
)

val rotation by animateFloatAsState(
    targetValue = if (isOpen) 180f else 0f
)

Read the animated value in your composable, apply it via Modifier or a property, and the framework interpolates between values whenever targetValue changes.

Complex and Coordinated Animations

**updateTransition** lets you orchestrate multiple animations that all key off the same state change:

val transition = updateTransition(targetState = selected, label = "card")
val bgColor by transition.animateColor { if (it) Color.Blue else Color.White }
val padding by transition.animateDp { if (it) 24.dp else 16.dp }
val scale by transition.animateFloat { if (it) 1.05f else 1f }

Every property changes in lockstep, on the same timeline, and you can tune each curve independently.

**InfiniteTransition** is for looping animations — loading spinners, pulsing indicators, idle states:

val infinite = rememberInfiniteTransition()
val alpha by infinite.animateFloat(
    initialValue = 0.3f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(800),
        repeatMode = RepeatMode.Reverse
    )
)

Performance Considerations

One warning: animations recompose the parts of your UI that read them, often at 60+ fps. Over-recomposition is the number-one performance footgun in CMP.

A few rules that will save you pain:

  • Prefer the built-in animation APIs. They’re tuned for the Skia rendering pipeline and skip as many layers of the composition as possible. Don’t build your own tick loop with LaunchedEffect and a while loop unless you have a very specific reason.
  • Read animated values as late as possible. If only a leaf node needs the animated color, don’t destructure it at the top of a huge composable where it’ll force the whole subtree to recompose.
  • Use the Layout Inspector and Compose Compiler reports. They’ll tell you which composables are recomposing more than they should.

Get those right and CMP animations will run butter-smooth on every target.

5. Advanced Considerations

Two topics separate hobby projects from production apps.

Platform-Specific Code with expect / actual

Not everything makes sense to share. Sometimes you want a native experience — an Apple Map on iOS, a Google Map on Android, a system file picker on desktop. KMP’s expect / actual mechanism, inherited by CMP, handles this cleanly.

In your common source set, declare what you need:

// commonMain
@Composable
expect fun MapView(location: LatLng, modifier: Modifier = Modifier)

In androidMain, implement with Google Maps:

@Composable
actual fun MapView(location: LatLng, modifier: Modifier) {
    GoogleMap(
        modifier = modifier,
        cameraPositionState = rememberCameraPositionState {
            position = CameraPosition.fromLatLngZoom(location.toGms(), 14f)
        }
    )
}

In iosMain, use MapKit via Compose’s UIKit interop:

@Composable
actual fun MapView(location: LatLng, modifier: Modifier) {
    UIKitView(
        factory = {
            MKMapView().apply {
                setRegion(/* ... */, animated = true)
            }
        },
        modifier = modifier
    )
}

Same composable name, same call site, platform-appropriate implementation. Your feature code never has to know.

Interoperability with SwiftUI and UIKit

One of the most underrated CMP features is that interop runs both ways.

You can embed SwiftUI or UIKit views inside CMP (via UIKitView and UIKitViewController on iOS) — useful when Apple ships something CMP doesn't wrap yet, like certain ARKit or VisionKit components.

And critically, you can embed CMP inside an existing iOS app. CMP exports a UIViewController from your shared module; your iOS app can present it like any other VC. This means migration doesn't have to be all-or-nothing. Ship one screen in CMP. Then another. Then a feature. Then the whole app.

That gradual path is, in my experience, the single biggest reason teams stick with CMP versus bouncing back to maintaining two UI codebases.

Wrapping Up

Compose Multiplatform isn’t “React Native for Kotlin.” It’s something more ambitious: a single declarative UI framework that renders natively on every major platform, backed by the same battle-tested engine Google ships on a billion Android phones.

If you’ve got a Kotlin backend, a Jetpack Compose Android app, or a KMP project already sharing business logic — CMP is a short step, not a leap. And if you’re starting fresh, it’s one of the most productive UI stacks available today.

Start small. Port one screen. Get a feel for the state model. Wire up a few animations. Before long, you’ll be shipping pixel-identical UI to Android, iOS, Desktop, and Web from the same commonMain folder — and wondering how you ever did it any other way.

Happy composing.

If you found this useful, a few claps help it find other developers staring at two UI codebases and wondering if there’s a better way. There is.


메타데이터
post_id
fb57da7bac35
slug
compose-multiplatform-cmp-one-ui-codebase-for-android-ios-desktop-and-web-fb57da7bac35
url
https://medium.com/@johnsafwat362/compose-multiplatform-cmp-one-ui-codebase-for-android-ios-desktop-and-web-fb57da7bac35
canonical_url
https://medium.com/@johnsafwat362/compose-multiplatform-cmp-one-ui-codebase-for-android-ios-desktop-and-web-fb57da7bac35
author_url
https://medium.com/@johnsafwat362
status
ok
fetched_at
2026-07-11 01:01:15