Building Beautiful UIs with Kotlin Multiplatform: Best Practices and Examples
Published May 11, 2026 ⦁ 19 min read

Building Beautiful UIs with Kotlin Multiplatform: Best Practices and Examples

You committed to Kotlin Multiplatform for the shared business logic. The networking layer is humming, your repository pattern is clean, and `commonMain` is where most of your code lives. Then the **kotlin multiplatform ui** question hits, and suddenly the architectural calm evaporates.

Three questions surface, usually in the same week:

  • One UI everywhere, or shared logic plus native UIs?
  • If you go with Compose Multiplatform, how do you keep the iOS version from feeling like a Material knockoff?
  • Where does the hybrid line sit, and who draws it?

Most KMP projects don't fail on business logic. They fail on the UI layer, where platform conventions punish naive sharing and where a six-week head start turns into a six-month cleanup. This is a field guide written from that vantage point — pragmatic, opinionated, not a Compose Multiplatform sales pitch.

What follows: the three UI patterns and how to choose between them, where Compose Multiplatform genuinely earns its keep versus where it still cracks, design tokens as architectural safeguard, the iOS idioms you can't fake without users noticing, and a 12-item pre-ship validation checklist.

A developer's dual-monitor desk setup — left monitor shows Android Studio with a Compose `@Composable` function open, right monitor shows iOS Simulator running the same screen. Coffee mug, mechanical keyboard, late-evening lighting. Shot from slightl

Table of Contents


The Three UI Patterns in Kotlin Multiplatform — And Why One Doesn't Win Universally

Every team building a kotlin multiplatform ui picks one of three strategies, often without realizing they had a choice. The choice is rarely revisited, and it shapes everything from hiring decisions to App Store review outcomes eighteen months later.

The three patterns:

  1. Compose Multiplatform everywhere. A single set of `@Composable` functions renders on iOS, Android, Desktop, and Web. According to the JetBrains Compose Multiplatform 1.6 release announcement, iOS support reached stable in May 2024.
  2. Shared logic plus native UI. KMP handles networking, persistence, and business logic in `commonMain`. SwiftUI builds the iOS UI. Jetpack Compose builds the Android UI. This is the pattern that Touchlab's KaMPKit demonstrates canonically.
  3. Hybrid. Compose Multiplatform for most screens. Native UI for screens where platform fidelity is non-negotiable — payments, camera, complex gestures, premium consumer experiences.

The decision matrix below summarizes the trade-offs that surface in production reports surveyed across community case studies and the ProAndroidDev visual guide to KMP.

CriterionCompose MP EverywhereShared Logic + Native UIHybrid
Visual parity on iOS85–90%98–100%92–95%
Visual parity on Android95–98%98–100%96–98%
Initial development speedFastSlowMedium
Team skills requiredKotlin + ComposeKotlin + SwiftUI + ComposeKotlin + Compose + targeted SwiftUI
iOS idiom complianceMediumHighHigh where it matters
3-year maintenance costLow–MediumHighMedium
Best-fit projectMVPs, internal tools, content appsiOS-first revenue productsApps with 1–3 idiom-critical screens
The fastest UI path isn't always the most maintainable one — and the prettiest design rarely survives the first platform review cycle.

The matrix kills the universal answer. A content-heavy app — RSS reader, dashboard, internal tool, B2B portal — gets roughly 80% of native quality from Compose Multiplatform at about 30% of the dual-codebase effort. Ship it and move on. A premium iOS-first consumer product where App Store rating depends on swipe-feel and haptic timing pays the dual-codebase cost or loses to a competitor who did.

Team composition decides this more often than the product spec. If your team is two Android engineers who learned Kotlin before they learned SwiftUI, the native-iOS path adds a hiring problem, not just a code problem. If your team has a senior iOS engineer who will reject any UI that ignores HIG, you'll end up hybrid whether you planned for it or not.

KMP Kit ships with a Compose Multiplatform architecture as its default, but the module structure makes the hybrid pattern viable without refactoring — see the feature breakdown for the design system and navigation scaffolding it includes. The point isn't which boilerplate you pick. The point is to pick consciously, with the matrix above as the lens.


Where Compose Multiplatform Genuinely Shines — And Where It Still Cracks

Compose Multiplatform reached stable iOS support in May 2024. It is production-ready for most apps. It is not yet pixel-perfect-iOS-ready, and pretending otherwise burns trust with iOS users in week three of beta.

What it genuinely does well

Unified state management. A single `MutableStateFlow` drives identical UI updates on both platforms. No parallel ViewModel implementations, no "the Android version got the new error state but iOS didn't." The Kotlin Multiplatform documentation covers the shared ViewModel pattern in detail.

Design system consistency. Token definitions in `commonMain` — color, typography, spacing as Kotlin data classes consumed via `CompositionLocal` — render identically across platforms. The "Android version drifted again" problem disappears because there is no Android version to drift.

Animation API parity. `animateFloatAsState`, `AnimatedVisibility`, `Transition` — all work on iOS with identical APIs. Frame timing differences are sub-perceptual on a 60Hz iPhone in practice.

Rapid prototyping. A working feed screen with a list, pull-to-refresh, and navigation goes from empty `App()` to running on both simulators in under an afternoon for an experienced Compose developer. That speed compounds across an MVP.

Compose Multiplatform won't replace native development — it replaces the need to maintain two entirely separate codebases.

Where it cracks

Each of these is real, documented in the Compose Multiplatform GitHub issue tracker, and avoidable with the right plan.

iOS swipe-to-go-back. Compose Multiplatform's navigation library approximates this but doesn't match the rubber-band physics or the screen-edge gesture priority that iOS users feel without consciously noticing. Fix: bridge to UIKit's `UINavigationController` for navigation transitions if your app is iOS-first.

Bottom sheets. iOS users expect `.sheet()` modal behavior — drag handle, partial detents, swipe-down dismissal with momentum. Compose Multiplatform's `ModalBottomSheet` is closer to Material spec, and iOS users notice. Fix: native bridge for premium iOS apps, or accept the Material feel for internal tools.

Pull-to-refresh feel. Material's progress indicator versus iOS's elastic spring — different physics, different visual language. Compose Material's `PullRefreshIndicator` renders on iOS but looks Android-y. Fix: custom indicator using `platform.UIKit` interop, or accept it.

System fonts. San Francisco doesn't auto-apply on iOS. Without explicit configuration, iOS text feels subtly off in a way users perceive but rarely articulate. Fix: query platform in `commonMain` via `expect`/`actual` and apply the system font on iOS, Roboto on Android.

Haptics. iOS users feel `UIImpactFeedbackGenerator` haptics in dozens of system interactions without thinking about it. Compose Multiplatform doesn't ship a unified haptics API. Fix: `expect`/`actual` declaration calling `UIImpactFeedbackGenerator` on iOS and `HapticFeedback` on Android.

Two iPhones (or iPhone + Pixel) standing upright side-by-side on a clean desk, both displaying the same chat or feed UI from a Compose Multiplatform app. A subtle annotation arrow points to the bottom-sheet drag area on the iOS device. Soft natural l

The honest reframe

Two failure modes get conflated, and the conflation is what causes most project pain:

  1. "Compose Multiplatform doesn't support this." Rare. Almost everything is technically achievable through UIKit interop.
  2. "Compose Multiplatform supports this, but the default feels wrong on iOS." Common. This is the actual work of shipping a great cross-platform UI.

The second category is where teams underestimate effort. A realistic estimate: budget roughly 15–25% additional iOS polish work on top of "it runs." Teams that budget zero extra time are the ones rewriting screens in TestFlight.


Design Tokens as Architectural Safeguard — A Four-Step Build Order

A design system in KMP isn't a Figma file with nice colors. It's the contract that prevents your Android team and your iOS designer from diverging in week six. Tokens are the unit of that contract. The W3C Design Tokens Community Group standard provides the naming conventions worth adopting before you write your first `Color(0xFF...)`.

Step 1 — Define tokens in commonMain as Kotlin data classes

Build three core token types:

  • `AppColors` — a data class with semantic names like `primary`, `surface`, `onSurface`, `error`. Not literal names like `blue500`. Semantic naming is what lets you change values in one place when iOS design review demands a warmer primary.
  • `AppTypography` — `displayLarge`, `bodyMedium`, `labelSmall`, each a `TextStyle` value with explicit font, weight, size, and line height.
  • `AppSpacing` — `xs`, `sm`, `md`, `lg`, `xl` mapped to 4, 8, 16, 24, 32 dp. The 8-point grid is the industry default and survives every design review you'll ever sit through.

Expose tokens via `CompositionLocal` using Jetpack Compose's CompositionLocal pattern so any composable reads `LocalAppColors.current.primary`. When the warmer primary lands, you change one value, not 47 hardcoded color calls scattered across feature modules.

Step 2 — Wrap platform idioms in shared composables with override hooks

Build `AppButton`, `AppTextField`, `AppCard`, `AppBottomSheet` as `@Composable` functions in `commonMain`. Each accepts a style parameter or reads from a `CompositionLocal` for platform-specific tweaks.

`AppButton` uses Material 3's `Button` underneath but applies token-driven padding and corner radius. For iOS, you optionally swap the underlying implementation via `expect`/`actual` if HIG demands a system-style button.

The critical rule: never let feature code call `androidx.compose.material3.Button` directly. Always route through your design system. This is what makes the iOS tweak in month nine a one-file change instead of a 200-file change. Teams that skip this rule rediscover the value of design systems the hard way, usually mid-rebrand.

A laptop screen showing a split IDE view — left pane shows a Kotlin file with an `AppColors` data class visible (color tokens defined), right pane shows a Compose preview with a button component rendering with those tokens applied. Slight angle, scre

Step 3 — Test the system on device before building features

Build a "Design System Catalog" screen — a scrollable gallery of every token and component. Ship it as an internal screen behind a debug flag.

Test on real hardware:

  • iPhone SE (small screen, older hardware, lower contrast LCD)
  • iPhone 15 Pro (Dynamic Island, OLED, large screen)
  • Pixel 4a (mid-range Android)
  • A foldable if you support one

Many visual issues — touch target size, font rendering, color contrast under different displays — only surface on hardware. Simulators lie, gently and consistently, especially about color accuracy and gesture timing.

Step 4 — Document idiom compromises explicitly

For each design system component, maintain a one-line note in code comments or the module README:

  • `AppBottomSheet`: "Approximates iOS sheet detents; not a true `.sheet()` replacement. For iOS-first apps, override via native bridge."
  • `AppButton`: "Material 3 ripple on Android, fade on iOS. Acceptable for v1."
  • `AppPullToRefresh`: "Material indicator on both platforms. iOS users will notice; revisit if iOS retention drops below target."

This documentation is what prevents the "why does this feel weird on iOS?" complaint in beta from becoming a panicked refactor in week ten. Explicit compromises are cheaper than implicit ones.

Production KMP boilerplates ship with this scaffolding pre-built — the design system module structure (`tokens/`, `components/`, `theme/`) is the pattern worth copying, regardless of which starter you choose.


Native UI vs. Compose vs. Hybrid — A Trade-Off Table That Reflects Production Reality

The dichotomy "Compose Multiplatform vs. native" is misleading. Production apps almost always end up with at least some native code on at least one platform. The real question: which screens, and why?

Screen TypeNative iOS-OnlyCompose MPHybrid
Authentication (Sign in with Apple, biometrics)Best fitWorkable, adds riskCompose UI + native auth SDK
Standard list / feed screenOverkillBest fitOverkill
Settings / preferencesOverkillBest fitNot needed
Payment / IAP UIBest fit (StoreKit)Compliance riskCompose UI + native StoreKit
Camera / image captureBest fitLimited Compose supportNative camera view in Compose layout
Custom video playerBest fit (AVPlayer)Workable for basic playbackCompose chrome + native player
Complex gesture canvasBest fitBasic gestures onlyCompose UI shell + native canvas
Onboarding / marketingOverkillBest fitNot needed
Map screensBest fit (MapKit)No first-class supportCompose overlay on native map

Analysis

Authentication and payments aren't shared because Apple's review guidelines functionally require native StoreKit and Sign in with Apple prompts. The App Store Review Guidelines section 3.1.1 is unambiguous about in-app purchases for digital goods. Fighting this costs you weeks and possibly an App Store rejection. The pragmatic pattern: keep the surrounding UI in Compose Multiplatform, but route auth and IAP calls through SDKs that handle native prompts — Firebase Auth for authentication, RevenueCat for subscriptions. This is the architectural shape KMP Kit assumes by default, with the auth and payments modules already wired to native flows.

Standard content screens — feed, settings, profile, search results — are where Compose Multiplatform earns 80%+ of its value. Build these in shared code and move on with your life. The maintenance savings compound month over month.

Camera, maps, and complex gesture surfaces don't have mature Compose Multiplatform equivalents yet. The hybrid pattern is the answer: a Compose `Box` that hosts a native `UIViewController` (iOS) or `View` (Android) via the UIKit interop API. This sounds messy but with clear naming conventions — `platform/ios/CameraView.kt` calling `actual fun PlatformCameraView()` — it stays sustainable for years.

Dispel the "hybrid is unmaintainable" myth directly. Hybrid is only unmaintainable when teams refuse to draw architectural lines. The line that works: shared code owns state, navigation, and theming. Native code owns platform-specific surfaces and is treated as a leaf dependency, never the other way around. State flows down from shared; UI events flow up. No native screen owns business logic.

The rule of thumb: start everything in Compose Multiplatform. Drop to native only when a specific screen demonstrably fails — not preemptively. Premature native-ization is as expensive as premature optimization, and twice as hard to undo.


The iOS Idioms You Cannot Fake — A Field Guide to Platform Compliance

Every cross-platform UI eventually hits the same five iOS idioms that, if ignored, make users feel something is "off" without being able to name what. Naming them is the first step to handling them.

Safe Area Insets and the notch/Dynamic Island. iOS devices since the iPhone X (2017) have non-rectangular safe areas. Compose Multiplatform exposes these via `WindowInsets.safeDrawing` as documented in Jetpack Compose's insets guide. The mistake: applying `Modifier.padding(16.dp)` to the screen root and ignoring insets. Your top bar disappears under the Dynamic Island on iPhone 15 Pro. The fix: every top-level `Scaffold` or `Column` should consume `WindowInsets.safeDrawing.asPaddingValues()`. Test on devices with notches, Dynamic Island, and home indicators. Decision: always handle in Compose. No native bridge needed.

The swipe-to-go-back gesture. iOS users swipe from the left edge to navigate back hundreds of times a day. They don't think about it; they notice immediately when it's missing or feels sluggish. Compose Multiplatform's navigation supports this via predictive back gesture APIs, but the rubber-band physics aren't identical to UIKit's `UINavigationController`. The friction is most acute on premium consumer apps where users have high baseline expectations. Decision: for consumer iOS apps, bridge to a production-ready navigation system. For internal or B2B apps, the Compose approximation is acceptable.

System fonts. San Francisco on iOS, Roboto on Android. Defaulting to a custom font everywhere is a tell — power users know what an app built without platform font respect feels like, even if they can't articulate why it bothers them. Use `expect`/`actual` to declare a `platformDefaultFontFamily()` that returns `FontFamily.Default` on Android and an SF-configured `FontFamily` on iOS. Decision: always use platform defaults for body text. Custom fonts only for branded display type.

Haptics and audio feedback. iOS uses `UIImpactFeedbackGenerator` with `light`, `medium`, `heavy`, `soft`, and `rigid` variants for tactile UI confirmation. Android uses `HapticFeedbackConstants`. Compose Multiplatform doesn't unify these. The fix: declare `expect class PlatformHaptics` with `fun impact(intensity: HapticIntensity)` and implement on each platform. Apple's Human Interface Guidelines on playing haptics outline which interactions expect which intensity. Decision: always implement via expect/actual. Skipping haptics on iOS is the single most common "feels Android-y" complaint surfaced in beta feedback.

Accessibility semantics — VoiceOver and TalkBack. Both platforms read screen content aloud, but their conventions differ. VoiceOver expects `accessibilityLabel`, `accessibilityHint`, `accessibilityTraits`. TalkBack uses `contentDescription` and Compose's `semantics {}` block. The Jetpack Compose accessibility documentation outlines the semantics modifier; Compose Multiplatform translates these reasonably to both platforms — but only if you actually annotate your composables. Test with the Android accessibility scanner and iOS VoiceOver for every screen. Decision: handle in Compose via `semantics`; test on real devices with screen readers enabled.

The difference between an app iOS users tolerate and an app they recommend is usually the sum of these five details, not any single one.


The Pre-Ship UI Validation Checklist — 12 Items That Catch What Simulators Miss

Shipping a multiplatform UI without a validation rig is shipping blind. The checklist below is what separates beta-quality from App Store-quality, organized into four categories.

Visual regression

1. Screenshot regression tests cover every primary screen. Use Roborazzi for Compose snapshot tests in `commonTest`. Run them in CI on every PR. The cost: 30–120 seconds per CI run. The benefit: a typo in a token file that breaks 40 screens shows up before merge, not after release.

2. Light mode and dark mode both tested per screen. Roughly half of iOS users run dark mode system-wide based on widely cited usage patterns. Compose's `MaterialTheme` handles the swap, but only if your tokens are dark-mode aware. Run snapshot tests twice — once with `isSystemInDarkTheme()` mocked true, once false. Both passes must be green.

3. Snapshot tests run on at least three device profiles. Small phone (iPhone SE or Pixel 4a), large phone (iPhone 15 Pro Max or Pixel 8 Pro), tablet or foldable. Layouts that look fine at 390pt break at 320pt and stretch awkwardly at 430pt. Three profiles is the minimum to catch the common breakage; five is better if your CI budget permits.

Typography and localization

4. Dynamic Type and font size scaling tested at 200%. iOS users with low vision routinely run at 150–200% text size. If your `Row` containing a button breaks at large font sizes, you'll get one-star App Store reviews citing accessibility — and rightly so. Compose handles scaling via `sp` units, but verify visually. Don't just trust the unit.

5. Tested with German and Japanese pseudo-strings. German averages roughly 20–35% longer than English in practice; Japanese can be about 30% shorter and vertically taller. Run your screens with `de` and `ja` locales. Buttons truncate, headers wrap, layouts break in ways you can't predict from the English version. Catch this before the localization vendor delivers final strings, not after.

6. RTL (Arabic, Hebrew) layout mirroring verified. Compose handles `LayoutDirection.Rtl` automatically for most composables — but only for ones using `start`/`end` instead of `left`/`right`. One stray `Modifier.padding(start = ...)` mirrors correctly. One `Modifier.absolutePadding(left = ...)` doesn't. The bugs are subtle and accumulate.

You can't ship a beautiful multiplatform UI without a test rig that sees what both platforms actually see — not what your simulator promises.

Performance

7. 60fps maintained on the lowest supported device under scroll load. Use Android Studio's Layout Inspector and iOS Instruments' Time Profiler. Compose recompositions in hot paths — lists, animated views, search results — are the usual culprit. Profile early; recomposition debt compounds and is much harder to fix in week twelve than week three.

8. App launch (cold start) under 2 seconds on a mid-range device. Compose initialization, Koin DI graph creation, and initial composition all contribute. Measure with `adb shell am start -W` on Android and Xcode's launch metrics on iOS. If you're over 2 seconds at MVP, you'll be over 4 seconds by v1.5 unless you intervene.

9. Memory baseline under 150 MB at idle. Multiplatform image loading via Coil is efficient but un-tuned caches grow fast. Verify with Android Studio's Memory Profiler and iOS Instruments' Allocations. Set explicit cache size limits in production builds.

Platform polish

10. iOS Safe Area and Android edge-to-edge both rendering correctly. Run on iPhone with Dynamic Island and Android 15 with edge-to-edge enforcement. Both will visibly break a screen that ignored insets. Verify the status bar, the navigation bar, and the keyboard region.

11. Haptic feedback fires on primary actions on iOS. Tap your primary CTAs on a real iPhone. If you don't feel anything when you tap a confirmation button, your `expect/actual` haptics implementation isn't wired up. Simulators don't haptic, which is why this slips through.

12. Designer, iOS engineer, and Android engineer walkthrough completed. No automated test catches "this feels wrong." A 30-minute walkthrough on real devices, one screen at a time, with three sets of eyes — this is the single highest-ROI QA activity in shipping a cross-platform UI. Production CI/CD pipelines like the automated publishing workflows included with KMP Kit handle items 1–3 and 7–9 automatically. Items 10–12 are human-only and shouldn't be automated; the friction is the point.


The Production Learning Path — Six Resources That Compress the Curve

Learning Compose Multiplatform in isolation produces toy apps. Learning it through the lens of production architecture produces shippable apps. The six resources below, in order, compress months of self-direction.

Start with Jetpack Compose fundamentals, not Compose Multiplatform. Google's official Compose Pathway teaches the mental model — recomposition, state hoisting, `remember`, side effects — that Compose Multiplatform inherits unchanged. Skipping this and starting with Compose Multiplatform is why developers hit walls in week three. The wall is usually a Compose concept, not a multiplatform one, and the multiplatform docs assume you've already cleared it.

Read the official Compose Multiplatform documentation cover to cover. JetBrains' docs are dense but accurate. Focus on the UIKit interop section, lifecycle handling, and resource loading. These are where Compose Multiplatform diverges from Jetpack Compose, and where most production bugs originate.

Study one production-grade KMP boilerplate before building from scratch. Reading a working architecture saves weeks. Touchlab's KaMPKit demonstrates the shared-logic-plus-native-UI pattern clearly. For the full Compose Multiplatform pattern with authentication, payments, navigation, and CI/CD already wired, see the pricing tiers to find the one that matches your project stage. Either approach is faster than rediscovering the patterns through trial and error.

Set up screenshot regression testing on day one of your project, not day ninety. Configure Roborazzi in your `commonTest` source set before you write your second screen. The discipline of "every screen has a snapshot test" is impossible to retrofit and trivial to maintain from the start. GitHub Actions runs it free on public repos and cheap on private ones.

Join Kotlinlang Slack #multiplatform and #compose-ios channels. Sign up at https://kotlinlang.slack.com. These channels surface real production friction faster than any blog — JetBrains engineers and senior community contributors answer within hours. Lurking for two weeks teaches you which problems are common (and worth solving generically) versus rare (and worth working around).

Subscribe to the Compose Multiplatform release notes. The release page ships approximately every 6–8 weeks. Each release moves the "what's native-required versus what's supported" line. Reading release notes monthly keeps your hybrid decisions current. Teams that don't end up rebuilding native bridges for features Compose Multiplatform now supports natively — work that didn't need to happen.

The developers who ship great kotlin multiplatform ui work aren't the ones who read the most. They're the ones who shipped a small thing, validated it on real devices, and iterated. Pick a 4-week project, use one of the boilerplates above, ship to TestFlight and Internal Testing by week three, and let the friction teach you what no article can.

We use cookies to enhance your experience and analyze site usage. Learn more