compose-multiplatform

v2026.09.24

Compose Multiplatform (CMP) by JetBrains — declarative UI framework that runs on Android, iOS, JVM Desktop, and Web (Wasm). Built on top of Jetpack Compose. Covers @Composable functions, state hoisting, side effects, navigation (Voyager, Decompose), Material 3, theming, and platform-specific UI bridging on iOS. USE WHEN: user mentions "Compose Multiplatform", "@Composable", "remember", "MutableState", "Compose iOS", "Compose Desktop", "Compose Wasm", "JetBrains Compose", "Voyager", "Decompose", "compose-multiplatform-resources" DO NOT USE FOR: KMP module setup (gradle, expect/actual) - use `mobile/kotlin-multiplatform` DO NOT USE FOR: Jetpack Compose Android-only - use `mobile/jetpack-compose` DO NOT USE FOR: SwiftUI native - use `mobile/ios-native` or `languages/swift` DO NOT USE FOR: Pure Kotlin language - use `languages/kotlin`

GitHub
Install command
npx skhub add claude-dev-suite/compose-multiplatform
Markdown
SKILL.md

Compose Multiplatform

References: ios-platform.md for iOS-specific patterns (UIViewController interop, gesture differences, native scroll). navigation-di.md for navigation libraries (Voyager, Decompose) and DI integration. theming.md for Material 3 + custom design systems, dark mode, dynamic color.

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: compose-multiplatform.

What CMP Is

Compose Multiplatform brings Jetpack Compose to Android, iOS, JVM Desktop, and Web (Wasm). Same @Composable code renders on every target via Skia.

Status (Mid-2026):

  • Android: Stable (it's Jetpack Compose underneath)
  • Desktop: Stable
  • iOS: Stable since 1.8.0 (early 2025)
  • Web (Wasm): Beta — usable for prototypes, some gaps

Trade-offs vs native:

  • ✅ Single UI codebase, fast iteration
  • ✅ Pixel-perfect parity, custom design systems easy
  • ⚠️ iOS gestures, accessibility, system fonts, share sheets need bridging
  • ⚠️ Bundle size larger than pure SwiftUI (Skia ~5-8MB)
  • ⚠️ iOS Core Animation transitions don't apply automatically

Minimal Setup

// shared/build.gradle.kts
plugins {
    kotlin("multiplatform") version "2.2.0"
    id("com.android.library") version "8.7.0"
    id("org.jetbrains.compose") version "1.8.0"
    kotlin("plugin.compose") version "2.2.0"   // Required for Kotlin 2.x
}

kotlin {
    androidTarget()
    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }
    jvm("desktop")

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation("org.jetbrains.compose.material:material-icons-extended:1.8.0")
        }
        androidMain.dependencies {
            implementation("androidx.activity:activity-compose:1.10.0")
        }
        desktopMain.dependencies {
            implementation(compose.desktop.currentOs)
        }
    }
}

compose.resources {
    publicResClass = true
    packageOfResClass = "com.example.shared.resources"
}

First Composable

// shared/src/commonMain/kotlin/App.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun App() {
    MaterialTheme {
        var count by remember { mutableStateOf(0) }
        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Count: $count", style = MaterialTheme.typography.headlineMedium)
            Button(onClick = { count++ }) {
                Text("Increment")
            }
        }
    }
}

Wiring Per Platform

Android

// apps/android/MainActivity.kt
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

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

iOS

// shared/src/iosMain/kotlin/MainViewController.kt
import androidx.compose.ui.window.ComposeUIViewController

fun MainViewController() = ComposeUIViewController { App() }
// apps/ios/ContentView.swift
import SwiftUI
import Shared

struct ContentView: View {
    var body: some View {
        ComposeView()
            .ignoresSafeArea(.keyboard)
    }
}

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

Desktop

// apps/desktop/Main.kt
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

fun main() = application {
    Window(onCloseRequest = ::exitApplication, title = "BHODL") {
        App()
    }
}

State

remember and mutableStateOf

@Composable
fun NameField() {
    var name by remember { mutableStateOf("") }
    OutlinedTextField(
        value = name,
        onValueChange = { name = it },
        label = { Text("Name") },
    )
}

remember survives recomposition; lost on configuration change/process death. Use rememberSaveable to persist across config changes.

var name by rememberSaveable { mutableStateOf("") }

State Hoisting

Lift state to the lowest common ancestor. Children become stateless and reusable.

@Composable
fun NameForm(
    name: String,
    onNameChange: (String) -> Unit,
    onSubmit: () -> Unit,
) {
    Column {
        OutlinedTextField(value = name, onValueChange = onNameChange)
        Button(onClick = onSubmit) { Text("Submit") }
    }
}

@Composable
fun ParentScreen() {
    var name by remember { mutableStateOf("") }
    NameForm(
        name = name,
        onNameChange = { name = it },
        onSubmit = { /* ... */ },
    )
}

Collecting Flows

@Composable
fun WalletScreen(viewModel: WalletViewModel) {
    val state by viewModel.state.collectAsState()

    when (val s = state) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Success -> WalletList(s.wallets)
        is UiState.Error -> ErrorView(s.message)
    }
}

collectAsState works on all CMP targets (uses platform-appropriate Flow collection).

Side Effects

EffectUse case
LaunchedEffect(key1, ...)Run suspending work; cancels on key change
DisposableEffect(key)Setup with cleanup (listeners)
SideEffect { }Run on every successful recomposition (rare)
produceStateConvert non-Compose source to State
derivedStateOfMemoize computation depending on multiple states
rememberCoroutineScope()Launch coroutines tied to composable lifecycle
snapshotFlow { ... }Convert State to Flow
@Composable
fun BalanceObserver(walletId: String, repo: WalletRepository) {
    val balance by produceState<Balance?>(initialValue = null, walletId) {
        value = repo.getBalance(walletId)
    }
    Text("Balance: ${balance?.confirmed ?: "..."}")
}

@Composable
fun TickerExample() {
    var seconds by remember { mutableStateOf(0) }
    LaunchedEffect(Unit) {
        while (true) {
            delay(1000)
            seconds++
        }
    }
    Text("Elapsed: $seconds")
}

@Composable
fun ScrollPositionTracker(scrollState: ScrollState) {
    LaunchedEffect(scrollState) {
        snapshotFlow { scrollState.value }
            .collect { offset -> log("scroll: $offset") }
    }
}

Common UI Patterns

Lists

@Composable
fun WalletList(wallets: List<Wallet>) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(wallets, key = { it.id }) { wallet ->
            WalletItem(wallet)
        }
    }
}

key = { it.id } enables Compose to track items across reorders/animations.

Forms

@Composable
fun SendForm(onSend: (String, Long) -> Unit) {
    var address by remember { mutableStateOf("") }
    var amount by remember { mutableStateOf("") }
    val canSend = address.isNotBlank() && amount.toLongOrNull() != null

    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        OutlinedTextField(
            value = address,
            onValueChange = { address = it },
            label = { Text("Address") },
            singleLine = true,
        )
        OutlinedTextField(
            value = amount,
            onValueChange = { if (it.all(Char::isDigit)) amount = it },
            label = { Text("Amount (sats)") },
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
            singleLine = true,
        )
        Button(
            onClick = { onSend(address, amount.toLong()) },
            enabled = canSend,
            modifier = Modifier.fillMaxWidth(),
        ) { Text("Send") }
    }
}

Dialogs

@Composable
fun ConfirmDialog(
    title: String,
    text: String,
    onConfirm: () -> Unit,
    onDismiss: () -> Unit,
) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = { Text(text) },
        confirmButton = {
            TextButton(onClick = onConfirm) { Text("Confirm") }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) { Text("Cancel") }
        },
    )
}

Resources

import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource

@Composable
fun WelcomeBanner() {
    Column {
        Image(
            painter = painterResource(Res.drawable.logo),
            contentDescription = null,
        )
        Text(stringResource(Res.string.welcome))
    }
}

Resources live in shared/src/commonMain/composeResources/:

  • drawable/ (PNG, SVG via androidx.compose.ui.graphics.vector)
  • font/ → Font(Res.font.inter, FontWeight.Normal)
  • values/strings.xml → multi-language via values-it/strings.xml
  • files/ → raw bytes via Res.readBytes("files/data.json")

Theming — Material 3

private val LightColors = lightColorScheme(
    primary = Color(0xFFFF9500),       // BHODL orange
    onPrimary = Color.White,
    surface = Color(0xFFFAFAFA),
    onSurface = Color.Black,
)

private val DarkColors = darkColorScheme(
    primary = Color(0xFFFFB740),
    onPrimary = Color.Black,
    surface = Color(0xFF121212),
    onSurface = Color.White,
)

@Composable
fun BhodlTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {
    val colors = if (darkTheme) DarkColors else LightColors
    MaterialTheme(
        colorScheme = colors,
        typography = bhodlTypography,
        shapes = bhodlShapes,
        content = content,
    )
}

For deeper customization (custom design tokens, multi-brand) see theming.md.

Modifier Order Matters

// padding inside background → padding shows background
Box(
    Modifier
        .background(Color.Red)
        .padding(16.dp)
)

// padding outside background → padding empty
Box(
    Modifier
        .padding(16.dp)
        .background(Color.Red)
)

Modifier chain executes left-to-right for layout/measurement. Always apply clickable, focusable, clip in the right position.

Performance

Use remember Wisely

// ❌ Bad — calculation runs every recomposition
@Composable
fun ExpensiveItem(items: List<Item>) {
    val sorted = items.sortedBy { it.priority }    // O(n log n) per recomp
    LazyColumn { items(sorted) { Item(it) } }
}

// ✅ Good — memoized
@Composable
fun ExpensiveItem(items: List<Item>) {
    val sorted = remember(items) { items.sortedBy { it.priority } }
    LazyColumn { items(sorted) { Item(it) } }
}

Stable Types

Mark data classes @Immutable or @Stable to help Compose skip recomposition:

@Immutable
data class Balance(val confirmed: Long, val pending: Long)

Without it, Compose may recompose every time the parent does, even if the same instance is passed.

derivedStateOf for Multi-State Computations

val isFormValid by remember {
    derivedStateOf {
        address.isNotBlank() && amount.toLongOrNull() != null && amount.toLong() > 0
    }
}

Recomputes only when one of the dependencies changes value — not on every recomposition.

Animations

@Composable
fun ExpandableCard(text: String) {
    var expanded by remember { mutableStateOf(false) }

    Card(
        modifier = Modifier.animateContentSize(),
        onClick = { expanded = !expanded },
    ) {
        Column(Modifier.padding(16.dp)) {
            Text("Tap to expand", style = MaterialTheme.typography.titleMedium)
            if (expanded) {
                Text(text, modifier = Modifier.padding(top = 8.dp))
            }
        }
    }
}

@Composable
fun PulseDot() {
    val infiniteTransition = rememberInfiniteTransition()
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.3f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(800, easing = LinearEasing),
            RepeatMode.Reverse,
        ),
    )
    Box(
        Modifier
            .size(12.dp)
            .alpha(alpha)
            .background(Color.Green, CircleShape)
    )
}

Anti-Patterns

Anti-patternWhy it's badCorrect approach
Mutable lists in state (mutableListOf() in mutableStateOf)Compose doesn't observe additionsUse mutableStateListOf() or assign new list
Heavy work in composable bodyRuns on every recompUse remember, side effects, or move out
Reading state outside @ComposableWon't recomposePass via parameter, or use snapshotFlow
@Composable with side effectsOrder undefinedUse LaunchedEffect, DisposableEffect
Forgetting key in LazyColumn.itemsSlow scrolling, lost stateAlways pass stable key
Modifying Modifier chain inside loopRecreated each passHoist Modifier or use remember
Nested LazyColumn inside ColumnLayout errorUse single LazyColumn with item { } blocks for headers
Wrapping every screen in Box(Modifier.fillMaxSize())VerboseUse Scaffold for consistent structure

Anti-Patterns Specific to iOS

  • Pull-to-refresh: native iOS uses UIRefreshControl. Compose pullToRefresh is available but feels less native — consider hybrid UIViewControllerRepresentable
  • Native scroll bounce: enabled by default in CMP iOS, but customize via Modifier.scrollable if needed
  • Keyboard avoidance: Compose iOS doesn't auto-handle. Use Modifier.imePadding() and configure ComposeUIViewController keyboard mode
  • Share sheet: no Compose API — bridge to UIActivityViewController via UIViewControllerRepresentable
  • Haptics: use platform expect/actual to call UIImpactFeedbackGenerator / Vibrator

Testing

// commonTest with createComposeRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick

class CounterTest {
    @get:Rule val rule = createComposeRule()

    @Test fun `increments on click`() {
        rule.setContent { App() }
        rule.onNodeWithText("Count: 0").assertExists()
        rule.onNodeWithText("Increment").performClick()
        rule.onNodeWithText("Count: 1").assertExists()
    }
}

Compose tests run on JVM by default (Robolectric-style). For real device tests use androidUITest or iosTest.

For snapshot testing: Paparazzi (Square) on JVM, Roborazzi for Android. CMP iOS snapshot testing is still rough — use SwiftUI's snapshot tools at the app boundary.

When NOT to Use This Skill

ScenarioUse Instead
KMP setup (gradle, expect/actual)mobile/kotlin-multiplatform
Native iOS UI (SwiftUI, UIKit)mobile/ios-native (when added)
Pure Jetpack Compose Android-onlymobile/jetpack-compose (when added)
Pure Kotlin language patternslanguages/kotlin
Navigation deep divenavigation-di.md
iOS-specific gotchasios-platform.md
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

skills/frontend-frameworks/compose-multiplatform

Default branch

main

Latest commit

9496306

Tree SHA

fe4e2f1