Skip to content
Back to skills

Compose Multiplatform

ASecurity

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", "Decompos...

  • 31 stars
  • 0 votes
  • 0 copies
  • 5 views
  • Added September 8, 2026
ai-agentsgoswiftkotlinnodetestinggitapiperformancedocumentation

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned September 8, 2026

npx -y skills add claude-dev-suite/claude-dev-suite --skill compose-multiplatform --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Compose Multiplatform?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Compose Multiplatform
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/claude-dev-suite-compose-multiplatform/badge)](https://www.skillsdirectory.com/skills/claude-dev-suite-compose-multiplatform)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: compose-multiplatform
description: |
  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`
allowed-tools: Read, Grep, Glob, Write, Edit
---
# Compose Multiplatform

> **References**: [ios-platform.md](quick-ref/ios-platform.md) for iOS-specific patterns (UIViewController interop, gesture differences, native scroll). [navigation-di.md](quick-ref/navigation-di.md) for navigation libraries (Voyager, Decompose) and DI integration. [theming.md](quick-ref/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

```kotlin
// 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

```kotlin
// 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

```kotlin
// 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

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

fun MainViewController() = ComposeUIViewController { App() }
```

```swift
// 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

```kotlin
// 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`

```kotlin
@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.

```kotlin
var name by rememberSaveable { mutableStateOf("") }
```

### State Hoisting

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

```kotlin
@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

```kotlin
@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

| Effect | Use case |
|---|---|
| `LaunchedEffect(key1, ...)` | Run suspending work; cancels on key change |
| `DisposableEffect(key)` | Setup with cleanup (listeners) |
| `SideEffect { }` | Run on every successful recomposition (rare) |
| `produceState` | Convert non-Compose source to State |
| `derivedStateOf` | Memoize computation depending on multiple states |
| `rememberCoroutineScope()` | Launch coroutines tied to composable lifecycle |
| `snapshotFlow { ... }` | Convert State to Flow |

```kotlin
@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

```kotlin
@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

```kotlin
@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

```kotlin
@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

```kotlin
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

```kotlin
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](quick-ref/theming.md).

## Modifier Order Matters

```kotlin
// 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

```kotlin
// ❌ 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:

```kotlin
@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

```kotlin
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

```kotlin
@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-pattern | Why it's bad | Correct approach |
|---|---|---|
| Mutable lists in state (`mutableListOf()` in `mutableStateOf`) | Compose doesn't observe additions | Use `mutableStateListOf()` or assign new list |
| Heavy work in composable body | Runs on every recomp | Use `remember`, side effects, or move out |
| Reading state outside `@Composable` | Won't recompose | Pass via parameter, or use `snapshotFlow` |
| `@Composable` with side effects | Order undefined | Use `LaunchedEffect`, `DisposableEffect` |
| Forgetting `key` in `LazyColumn.items` | Slow scrolling, lost state | Always pass stable key |
| Modifying `Modifier` chain inside loop | Recreated each pass | Hoist Modifier or use `remember` |
| Nested `LazyColumn` inside `Column` | Layout error | Use single `LazyColumn` with `item { }` blocks for headers |
| Wrapping every screen in `Box(Modifier.fillMaxSize())` | Verbose | Use `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

```kotlin
// 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

| Scenario | Use Instead |
|----------|-------------|
| KMP setup (gradle, expect/actual) | `mobile/kotlin-multiplatform` |
| Native iOS UI (SwiftUI, UIKit) | `mobile/ios-native` (when added) |
| Pure Jetpack Compose Android-only | `mobile/jetpack-compose` (when added) |
| Pure Kotlin language patterns | `languages/kotlin` |
| Navigation deep dive | [navigation-di.md](quick-ref/navigation-di.md) |
| iOS-specific gotchas | [ios-platform.md](quick-ref/ios-platform.md) |

Files in this skill

  • SKILL.md16.4 KB
  • quick-ref/ios-platform.md10.4 KB
  • quick-ref/navigation-di.md10.8 KB
  • quick-ref/theming.md11.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.