Jetpack Compose continues to evolve, and one of the most interesting updates is the new Ripple API. If you’ve been building modern Android UIs, you’ve probably used ripple effects to give users visual feedback when they tap on buttons, cards, or other interactive elements. That subtle wave animation plays a big role in making interactions feel responsive and intuitive.
With the latest updates, Google has refined how ripple indications work in Compose. The new approach makes ripple effects more efficient, more customizable, and better aligned with Material Design 3.
In this article, we’ll explore what changed, why these updates matter, and how you can start using the new Ripple API in Jetpack Compose in your apps.
What’s Covered in This Guide
We’ll walk through:
What ripple effects are in Jetpack Compose
Why the ripple API was updated
How the new Ripple API works
How to implement it using Kotlin
Best practices for customizing ripple behavior
By the end, you’ll have a clear understanding of how the new ripple system works and how to apply it effectively in your Compose UI.
What Is Ripple in Jetpack Compose?
Ripple is the touch feedback animation shown when a user taps or presses a UI component.
For example:
Buttons
Cards
List items
Icons
Navigation items
When the user taps an element, a circular wave spreads from the touch point.
This animation improves:
User experience
Accessibility
Visual feedback
Interaction clarity
In Material Design, ripple is the default interaction effect.
In Jetpack Compose, ripple is typically used with clickable modifiers.
Kotlin
Modifier.clickable { }
By default, this modifier automatically adds ripple feedback.
Why the Ripple API Changed
For a long time, ripple effects in Jetpack Compose were implemented through the Indication system, typically using rememberRipple(). While this approach worked well, it came with a few limitations.
Composition overhead: Since rememberRipple() was a composable function, it participated in the recomposition cycle. In some cases, this introduced unnecessary overhead for something that should ideally remain lightweight.
Memory usage: Each usage created new state objects, which could increase memory usage when ripple effects were applied across many UI components.
Tight coupling with Material themes: The implementation was closely tied to Material 2 and Material 3. This made it less flexible for developers building custom design systems or UI frameworks.
To address these issues, the ripple implementation has been redesigned using the Modifier.Node architecture. This moves ripple handling closer to the rendering layer, allowing it to be drawn more efficiently without triggering unnecessary recompositions.
As a result, the updated API makes ripple behavior:
More performant
More consistent with Material 3
Easier to customize
Better aligned with the modern Indication system
Overall, this change simplifies how ripple effects are handled while improving performance and flexibility for Compose developers.
Old Ripple Implementation (Before the Update)
Before the New Ripple API in Jetpack Compose, developers often used rememberRipple().
interactionSource → tracks user interactions (press, hover, focus)
Although this worked well, it required extra setup for customization.
The New Ripple API in Jetpack Compose
The New Ripple API in Jetpack Compose simplifies ripple creation and aligns it with Material3 design system updates.
The ripple effect is now managed through Material ripple APIs and better indication handling.
In most cases, developers no longer need to manually specify ripple.
Default Material components automatically apply ripple.
Kotlin
Button(onClick = { }) {Text("Click Me")}
This button already includes ripple.
However, when working with custom layouts, you may still need to configure ripple manually.
Key Changes from Old to New
Key changes in Compose Ripple APIs (1.7+)
rememberRipple() is deprecated. Use ripple() instead. The old API relied on the legacy Indication system, while ripple() works with the new node-based indication architecture.
RippleTheme and LocalRippleTheme are deprecated. Material components no longer read LocalRippleTheme. For customization use RippleConfiguration / LocalRippleConfiguration or implement a custom ripple.
Many components now default interactionSource to null, allowing lazy creation of MutableInteractionSource to reduce unnecessary allocations.
The indication system moved to the Modifier.Node architecture. Indication#rememberUpdatedInstance was replaced by IndicationNodeFactory for more efficient rendering.
Key Differences at a Glance:
Basic Example Using the New Ripple API
Let’s start with a simple example by creating a clickable Box with a ripple effect. This demonstrates how touch feedback appears when a user interacts with a UI element.
Before looking at the new approach, here’s how ripple was typically implemented in earlier versions of Compose.
The previous implementation relied on rememberRipple(), which has now been replaced by the updated ripple API.
Using the New Ripple API:
Here’s how you can implement the same behavior using the updated ripple system.
Kotlin
@ComposablefunRippleBox() {val interactionSource = remember { MutableInteractionSource() } // Or pass null to lazy-init Box( modifier = Modifier .size(120.dp) .background(Color.LightGray) .clickable( interactionSource = interactionSource, indication = ripple(), // From material3 or material onClick = {} ) ){Text("Tap me!") }}
In many cases you can simply pass interactionSource = null, which allows Compose to lazily create it only when needed.
Understanding the Key Components
MutableInteractionSource
Kotlin
val interactionSource = remember { MutableInteractionSource() }
MutableInteractionSource emits interaction events such as:
Press
Focus
Hover
Drag
Indications like ripple observe these events to trigger animations.
clickable modifier
Kotlin
Modifier.clickable()
This makes the composable interactive and triggers ripple on tap.
ripple()
Kotlin
indication = ripple()
ripple() is the new ripple API in Jetpack Compose and replaces the deprecated rememberRipple() implementation.
By default:
The ripple color is derived from MaterialTheme
The ripple originates from the touch point
The ripple is bounded within the component by default
Unlike the previous API, ripple() is not a composable function and works with the newer Modifier.Node-based indication system, which reduces allocations and improves performance.
When the user taps the component, the ripple will appear red instead of the default theme color.
Example: Unbounded Ripple
By default, ripple is bounded, meaning it stays inside the component.
If you want ripple to spread outside the element:
Kotlin
indication = ripple( bounded = false)
Use Cases
Unbounded ripple works well for:
floating action buttons
icon buttons
circular elements
Example: Setting Ripple Radius
You can also control ripple size.
Kotlin
indication = ripple( radius = 60.dp)
The radius defines how far the ripple spreads from the touch point.
This can help match custom UI designs.
Advanced Customization: RippleConfiguration
If you want to change the color or the alpha (transparency) of your ripples globally or for a specific part of your app, the old LocalRippleTheme is out (deprecated). Instead, we use LocalRippleConfiguration.
The modern approach uses RippleConfiguration and LocalRippleConfiguration. This allows you to customize ripple appearance for a specific component or subtree of your UI.
Example: Custom Ripple
Kotlin
val myCustomRippleConfig = RippleConfiguration( color = Color.Magenta, rippleAlpha = RippleAlpha( pressedAlpha = 0.2f, focusedAlpha = 0.2f, draggedAlpha = 0.1f, hoveredAlpha = 0.4f ))CompositionLocalProvider( LocalRippleConfiguration provides myCustomRippleConfig) {Button(onClick = { }) {Text("I have a Magenta Ripple!") }}
RippleConfiguration
A configuration object that defines the visual appearance of ripple effects.
RippleAlpha
Controls the ripple opacity for different interaction states:
pressedAlpha
focusedAlpha
draggedAlpha
hoveredAlpha
CompositionLocalProvider
Wraps a section of UI and provides a custom ripple configuration to all child components that read LocalRippleConfiguration.
With the new ripple API in Jetpack Compose, many Material components already include ripple feedback by default. This means you usually don’t need to manually specify indication = ripple().
Examples include:
Button
Card (clickable version in Material3)
ListItem
IconButton
NavigationBarItem
These components internally handle interaction feedback using the ripple system.
Kotlin
Card( onClick = { }) {Text("Hello")}
In Material3, providing onClick automatically makes the Card clickable and displays the ripple effect.
No manual ripple indication is required.
Best Practices for Using the New Ripple API in Jetpack Compose
1. Prefer Default Material Components
Material components already include ripple behavior.
This keeps UI consistent with Material Design.
2. Avoid Over-Customizing Ripple
Too much customization can create inconsistent UX.
Stick with theme defaults unless necessary.
3. Use interactionSource = null Unless You Need It
In modern Compose versions, you usually do not need to create a MutableInteractionSource manually.
interactionSource can now be null, allowing Compose to lazily create it when needed
This simplifies the code and avoids unnecessary allocations.
If you need to observe interaction events, you can still provide your own MutableInteractionSource.
Conclusion
The New Ripple API in Jetpack Compose simplifies how developers implement touch feedback while improving performance and consistency.
Key takeaways:
Ripple provides visual feedback for user interactions
The new API replaces rememberRipple() with ripple()
Material components already include ripple by default
Custom components can easily add ripple using Modifier.clickable
The updated system improves performance and flexibility
If you build modern Android apps with Jetpack Compose, understanding the New Ripple API in Jetpack Compose is essential for creating responsive and user-friendly interfaces.
If you’ve been in the mobile and cross-platform world lately, you’ve probably heard a lot about Compose Multiplatform (CMP). It’s one of the fastest-growing ways to build apps that run on Android, iOS and the Web using a single shared UI approach.
But what exactly is CMP? And why are developers increasingly choosing it over other frameworks? In this post, we’ll break it down, with examples, comparisons and real reasons developers love it.
What Is Compose Multiplatform — Precisely
Compose Multiplatform (CMP) is a UI framework developed and maintained by JetBrains, built on top of Google’s Jetpack Compose runtime. It extends the Compose programming model — declarative, reactive, composable UI functions — beyond Android to iOS, Desktop (JVM), and Web (Kotlin/Wasm).
CMP is layered on top of Kotlin Multiplatform (KMP), which is the underlying technology for compiling Kotlin code to multiple targets: JVM (Android/Desktop), Kotlin/Native (iOS/macOS), and Kotlin/Wasm (Web). Understanding this layering matters architecturally:
Not a pixel-for-pixel clone of native UI widgets on every platform
Not a guarantee that code runs identically on all platforms — it compiles and runs on all platforms, with deliberate platform-specific divergences in rendering, gestures, and system behaviors
Current Platform Support: Honest Status
What “iOS API Stable” means precisely: JetBrains has declared the CMP public API surface stable, meaning they will not make breaking changes without a deprecation cycle. It does not mean:
Pixel-perfect parity with SwiftUI or UIKit
Complete VoiceOver/accessibility support (this is a known gap as of 2026)
Identical scroll physics to UIScrollView
Equivalent Xcode debugging experience to native Swift development
Teams shipping CMP-based iOS apps in production report success, but they do so with deliberate investment in iOS-specific testing, accessibility audits, and gesture tuning — not by assuming parity.
CMP vs Flutter vs React Native — Engineering Comparison
Compose Multiplatform vs Flutter
Both use a custom rendering engine (not native OS widgets) to draw UI. Key engineering differences:
Honest verdict: Flutter has a more mature cross-platform tooling story and stronger iOS accessibility today. CMP wins decisively if your team is already invested in Kotlin, Jetpack libraries, and Android-first development.
Compose Multiplatform vs React Native
React Native’s new architecture (JSI + Fabric renderer) significantly closes the performance gap that historically plagued the JavaScript bridge. The architectural difference from CMP:
CMP compiles Kotlin to native binaries — no runtime JS, no bridge
React Native (New Architecture) uses JSI for synchronous JS-to-native calls — faster than the old bridge, but still a JS runtime overhead
React Native renders actual native widgets on each platform; CMP renders via Skia
React Native is the right choice for web-first teams; CMP is the right choice for Kotlin-first teams
How CMP Works Under the Hood
Rendering Pipeline
CMP uses different rendering approaches per platform, which explains both its strengths and its platform-specific behavioral differences:
Kotlin
commonMain Compose Code │ ├── Android │ └── Jetpack Compose Runtime │ └── Android RenderNode / Canvas API │ └── Skia (via Android's internal pipeline) │ ├── iOS │ └── Skiko (Kotlin/Native bindings to Skia) │ └── Metal GPU API │ └── CAMetalLayer embedded in UIView │ ├── Desktop (JVM) │ └── Skiko │ └── OpenGL / DirectX / Metal (OS-dependent) │ └── Web └── Kotlin/Wasm + Skia compiled to WebAssembly └── HTML <canvas> element
Critical implication of this architecture: Because CMP on iOS renders through a CAMetalLayer-backed UIView (not through SwiftUI’s layout engine), layout behaviors, font metrics, shadow rendering, and scroll momentum physics are produced by Skia — not by iOS’s native compositor. This is why experienced iOS users may notice subtle differences. It is also why full SwiftUI NavigationStack integration with CMP-managed screens is architecturally complicated.
The KMP Foundation: expect/actual
The expect/actual mechanism is the primary tool for platform branching. It operates at compile time, not runtime:
Kotlin
// commonMain — declares the contractexpect funcurrentTimeMillis(): Long// androidMain - Android implementationactual funcurrentTimeMillis(): Long = System.currentTimeMillis()// iosMain - iOS implementation (using Kotlin/Native platform APIs)actual funcurrentTimeMillis(): Long = NSDate().timeIntervalSince1970.toLong() * 1000
expect/actual works for:
Top-level functions
Classes (with matching constructors)
Objects
Interfaces (less common; prefer interfaces in commonMain with actual implementations)
Typealiases (useful for mapping platform types)
expect class constructor limitation: When you declare expect class Foo(), every actual implementation must match the constructor signature. This creates a real problem for Android classes that require Context. The correct pattern uses dependency injection or a platform-provided factory, not a bare constructor — covered in detail in the DI section.
Project Structure and Modularization
The single-module structure shown in most tutorials works for demos. Production apps require modularization from the start — it affects build times, team ownership, and testability fundamentally.
:core:domain depends on nothing — it’s pure Kotlin, testable anywhere
:core:data depends on :core:domain interfaces only
Feature modules depend on :core:domain and :core:ui-components; never on each other
Platform entry points wire everything together via DI — they’re the only place with platform-specific imports
Gradle Configuration — The Real Picture
Here is a production-realistic Gradle configuration with current APIs (Kotlin 2.1.x):
Kotlin
// build-logic/src/main/kotlin/CmpLibraryPlugin.kt// Convention plugin applied to all shared library modulesimport org.jetbrains.kotlin.gradle.dsl.JvmTargetplugins {id("com.android.library")kotlin("multiplatform")id("org.jetbrains.compose")id("org.jetbrains.kotlin.plugin.compose")}kotlin {androidTarget {compilerOptions { // Note: kotlinOptions {} is deprecated jvmTarget.set(JvmTarget.JVM_11) } }listOf(iosX64(),iosArm64(),iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "SharedModule" isStatic = true// Static frameworks are required for proper Kotlin/Native// memory management with Swift ARC interop } }@OptIn(ExperimentalWasmDsl::class)wasmJs {browser() binaries.executable() }sourceSets { commonMain.dependencies {implementation(compose.runtime)implementation(compose.foundation)implementation(compose.material3)implementation(compose.ui)implementation(compose.components.resources)implementation(libs.lifecycle.viewmodel.compose) // Multiplatform ViewModelimplementation(libs.lifecycle.runtime.compose) // collectAsStateWithLifecycleimplementation(libs.navigation.compose) // Multiplatform navimplementation(libs.kotlinx.coroutines.core)implementation(libs.koin.compose) // DI } androidMain.dependencies {implementation(libs.androidx.activity.compose)implementation(libs.kotlinx.coroutines.android) // Provides Dispatchers.Main on Android } iosMain.dependencies {implementation(libs.kotlinx.coroutines.core)// Note: kotlinx-coroutines-core for Native provides// Dispatchers.Main via Darwin integration - requires explicit dependency } commonTest.dependencies {implementation(libs.kotlin.test)implementation(libs.kotlinx.coroutines.test) } }}
Known Gradle pain points in production:
Kotlin/Native compilation is 3–5× slower than JVM compilation. Enable the Kotlin build cache (kotlin.native.cacheKind=static in gradle.properties) and Gradle build cache
XCFramework generation for App Store distribution requires a separate XCFramework task — not included in the default template
The linkDebugFrameworkIosArm64 Gradle task must be connected to Xcode’s build phase; misconfiguration here is the #1 cause of “works on simulator, fails on device” issues
Keep isStatic = true on iOS framework targets. Dynamic frameworks are supported but add complexity to iOS app startup and Xcode integration
Correct Architectural Patterns
The Layered Architecture for CMP
Kotlin
┌─────────────────────────────────────────┐│ UI Layer (CMP) ││ Composables receive UiState, emit ││ events/callbacks. No business logic. │├─────────────────────────────────────────┤│ ViewModel Layer ││ Holds UiState (single StateFlow). ││ Orchestrates use cases. Maps domain ││ models to UI models. │├─────────────────────────────────────────┤│ Domain Layer ││ Use cases (interactors). Pure Kotlin. ││ No framework dependencies. │├─────────────────────────────────────────┤│ Data Layer ││ Repository implementations. ││ Ktor for network. SQLDelight for DB. ││ Platform-specific data sources. │└─────────────────────────────────────────┘
MVI with Single UiState (Preferred for CMP)
Multiple independent StateFlow properties in a ViewModel create impossible UI states and double recompositions. Use a single sealed UiState:
Kotlin
// Correct: Single state object prevents impossible states// and triggers exactly one recomposition per state changesealedclassProductListUiState {objectLoading : ProductListUiState()dataclassSuccess(val products: List<ProductUiModel>,val searchQuery: String = "" ) : ProductListUiState()dataclassError(val message: String,val isRetryable: Boolean ) : ProductListUiState()}// UiModel - separate from domain model// Only contains what the UI needs; formatted strings, not raw data@Immutable// Tells Compose compiler this is stable - critical for LazyColumn performancedataclassProductUiModel(val id: String,val name: String,val formattedPrice: String, // "$12.99" not 12.99 - formatting in ViewModel, not Composableval description: String,val imageUrl: String)
Why error.toUserFacingMessage() matters: On Kotlin/Native (iOS), exception.message can be null. Always map exceptions to typed error representations before exposing them to the UI layer.
State Management Done Right
State Hoisting — The Correct Pattern
The most common architectural mistake in Compose (multiplatform or not) is passing a ViewModel into a composable. This breaks testability, violates unidirectional data flow, and causes incorrect recomposition scoping.
The rule: Composables receive state (immutable data) and emit events (callbacks). They never hold or reference a ViewModel directly.
Kotlin
// Anti-pattern — breaks testability and state hoisting@ComposablefunProductListScreen(viewModel: ProductListViewModel) {val uiState by viewModel.uiState.collectAsState()// ...}// Correct - state in, events out@ComposablefunProductListScreen( uiState: ProductListUiState, onRetry: () -> Unit, onSearchQueryChanged: (String) -> Unit, onProductClick: (String) -> Unit, // Pass ID, not the whole object modifier: Modifier = Modifier // Always accept a Modifier parameter) {when (uiState) {is ProductListUiState.Loading ->LoadingContent(modifier)is ProductListUiState.Error ->ErrorContent( message = uiState.message, isRetryable = uiState.isRetryable, onRetry = onRetry, modifier = modifier )is ProductListUiState.Success ->ProductListContent( products = uiState.products, searchQuery = uiState.searchQuery, onSearchQueryChanged = onSearchQueryChanged, onProductClick = onProductClick, modifier = modifier ) }}
The ViewModel sits at the navigation/screen level, never inside a composable:
Kotlin
// In your navigation graph composable<ProductList> {val viewModel: ProductListViewModel = koinViewModel()val uiState by viewModel.uiState.collectAsStateWithLifecycle()// collectAsStateWithLifecycle is preferred over collectAsState —// it respects platform lifecycle and pauses collection when the app is backgroundedProductListScreen( uiState = uiState, onRetry = viewModel::loadProducts, onSearchQueryChanged = viewModel::onSearchQueryChanged, onProductClick = { productId -> navController.navigate(ProductDetail(productId)) } )}
remember vs rememberSaveable
Kotlin
@ComposablefunSearchBar( query: String, // Lifted state — parent owns it onQueryChange: (String) -> Unit, onSearch: (String) -> Unit, modifier: Modifier = Modifier) {// No local mutableStateOf needed — state is owned by caller// Only use remember for objects that are expensive to createval focusRequester = remember { FocusRequester() }OutlinedTextField(value = query, onValueChange = onQueryChange, modifier = modifier.focusRequester(focusRequester),// ... )}// For state that must survive configuration changes AND process death,// use rememberSaveable with a Saver if the type is not primitive:val scrollState = rememberSaveable(saver = ScrollState.Saver) { ScrollState(0) }
Lifecycle-Aware Collection
collectAsState() does not pause collection when the app is backgrounded on iOS. Use collectAsStateWithLifecycle() from lifecycle-runtime-compose:
Kotlin
// Lifecycle-aware — pauses when app is in background on all platformsval uiState by viewModel.uiState.collectAsStateWithLifecycle()// Always-on - continues collecting even when app is backgroundedval uiState by viewModel.uiState.collectAsState()
Type-Safe Navigation Across Platforms
String Routes Are Deprecated — Use Type-Safe Navigation
As of navigation-compose 2.8.x, type-safe navigation using @Serializable route objects is stable and the recommended approach. String-based routes are error-prone, refactoring-unsafe, and lack compile-time guarantees.
iOS back-swipe gesture: The multiplatform navigation-compose supports interactive back-swipe on iOS, but the animation curve and gesture threshold are Skia-rendered approximations of the native UINavigationController push/pop animation. They are close but distinguishable to trained iOS users. For apps where native-feel is paramount, consider using Decompose (a community navigation library) which supports fully native iOS transitions via UIKit integration.
Android back handling: The hardware back button and predictive back gesture (Android 14+) require explicit handling. Register a BackHandler composable where needed:
Kotlin
BackHandler(enabled = uiState is CheckoutUiState.InProgress) {// Prompt user before losing checkout progress showExitConfirmationDialog = true}
Web browser history: Navigation-compose on Wasm integrates with browser history via the History API, but deep link handling (initial URL → correct screen) requires setup in your Wasm entry point that the default template does not provide.
Platform-Specific Features via expect/actual
The Context Problem on Android — Solved Correctly
A common mistake is defining expect class with a no-arg constructor when the Android implementation needs Context. The correct approach uses dependency injection, not constructor parameters in the expect declaration:
The DI framework (Koin shown below) provides the platform-correct implementation to commonMain code — no expect/actual needed when the interface lives in commonMain.
Embedding Native Views
For platform-native components that cannot be reproduced in Compose (maps, WebViews, camera previews):
Kotlin
// features/map/src/iosMain/ — iOS-specific file@ComposablefunNativeMapView( latitude: Double, longitude: Double, modifier: Modifier = Modifier) {UIKitView( factory = {MKMapView().apply {// Configure once on creation showsUserLocation = true } }, update = { mapView ->// Called on recomposition when inputs changeval region = MKCoordinateRegionMake(CLLocationCoordinate2DMake(latitude, longitude),MKCoordinateSpanMake(0.01, 0.01) ) mapView.setRegion(region, animated = true) }, modifier = modifier )}
Important:UIKitView must be in iosMain, not commonMain. Expose it via an expect/actual composable or via conditional compilation if you need a platform-specific fallback in the shared screen.
iOS-Specific: Lifecycle, Interop, and Debugging
This section covers the most under-addressed topic in CMP guides. iOS lifecycle management is where most production incidents originate.
The iOS Lifecycle vs Android Lifecycle
On Android, ViewModel.viewModelScope is tied to Lifecycle.State.CREATED — coroutines are automatically cancelled when the ViewModel is cleared. On iOS, the mapping is:
iOS App States → CMP/Compose Lifecycle ───────────────────────────────────────────────── Active (foreground) → Lifecycle.State.RESUMED Inactive (transitioning)→ Lifecycle.State.STARTED Background (suspended) → Lifecycle.State.CREATED Terminated (clean exit) → Lifecycle.State.DESTROYED Killed by OS (OOM/force)→ DESTROYED not guaranteed
The critical issue: When an iOS app is backgrounded, the OS may suspend it entirely with no further CPU time. Coroutines in viewModelScope do not automatically pause on iOS the way Android’s lifecycle-aware components do. This means:
Kotlin
// Dangerous on iOS — will attempt network calls even when app is suspendedclassProductListViewModel : ViewModel() {init { viewModelScope.launch {// This may run after iOS has suspended your app,// causing unexpected behavior or battery drain productRepository.startPolling() } }}// Correct - use lifecycle-aware collectionclassProductListViewModel : ViewModel() {val uiState: StateFlow<ProductListUiState> = productRepository .productsFlow .map { it.toUiState() } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000),// WhileSubscribed stops the upstream flow when there are no collectors// (i.e., when the screen is not visible) initialValue = ProductListUiState.Loading )}
SharingStarted.WhileSubscribed(5_000) is the correct production pattern — it stops upstream flows 5 seconds after the last subscriber disappears (the screen leaves composition), which handles both backgrounding and navigation.
// iosMain — Kotlin entry point called from SwiftfunMainViewController() = ComposeUIViewController( configure = {// Configure the Compose host here// For example, register platform-specific implementations }) {// Koin DI initialization for iOSKoinApplication( application = { modules(platformModule(), sharedModule()) } ) {AppNavigation() }}
SwiftUI NavigationStack + CMP: You cannot simultaneously use SwiftUI NavigationStack for routing AND CMP’s NavHost for routing. Choose one as the source of truth. Mixing both causes double back-stack management and broken state restoration. The recommended approach for CMP-first apps is to let CMP’s NavHost own all navigation and wrap the entire CMP root as a single SwiftUI view.
Debugging Kotlin/Native on iOS
Xcode’s debugger does not understand Kotlin. For production crash debugging:
Kotlin/Native crash reports appear in Xcode Organizer as native crashes with mangled Kotlin symbols
You must use konan/bin/llvm-symbolizer with your app’s .dSYM file to demangle crash stacks
Sentry’s KMP SDK handles crash symbolication automatically and is the most production-proven option
For local debugging, enable Kotlin LLDB formatters by adding the Kotlin LLDB plugin to Xcode
Dependency Injection in CMP
DI is not mentioned in most CMP tutorials and is the first thing that breaks in real projects. Koin is the most production-proven multiplatform DI framework for CMP. Kodein-DI is a capable alternative.
CMP’s iOS accessibility support is the most significant production gap as of early 2026. This section must be understood before committing to CMP for any app serving users with disabilities or operating in regulated industries (healthcare, finance, government).
Current iOS Accessibility Status
JetBrains is actively improving iOS accessibility. Track progress at youtrack.jetbrains.com — search for “iOS accessibility CMP.”
Semantic Annotations — Always Provide Them
Even where CMP’s accessibility pipeline is strong, you must provide explicit semantics:
For apps where full iOS VoiceOver compliance is non-negotiable right now, consider:
Hybrid approach: Use CMP for Android + Desktop + Web, keep native SwiftUI for iOS
UIKitView fallback: Implement accessibility-critical screens as UIKit views wrapped in UIKitView
Wait for CMP 1.8+: JetBrains has prioritized iOS accessibility — the gap is closing
Performance: Real Numbers and Real Caveats
iOS Rendering Performance
Startup overhead: The Kotlin/Native runtime initialization time is the most cited performance concern. It is real and not fully eliminable, but it can be minimized:
Initialize the Kotlin runtime as early as possible in your Swift AppDelegate or @main struct, before any UI is shown
Use MainActor in Swift to ensure the CMP compositor is ready before the first frame
Memory Management on iOS
CMP’s memory behavior on iOS requires awareness of three interacting systems:
Kotlin/Native’s concurrent garbage collector (introduced in Kotlin 1.9.20) — significantly improved but still runs GC pauses under pressure
Swift’s ARC — automatic reference counting at the Swift/Kotlin boundary
// Register for iOS memory pressure notifications and clear image caches// This should be done in your iosMain platform setupclassIosMemoryPressureHandler(privateval imageLoader: ImageLoader// Coil's ImageLoader) {funregister() { NSNotificationCenter.defaultCenter.addObserverForName( name = UIApplicationDidReceiveMemoryWarningNotification, `object` = null, queue = NSOperationQueue.mainQueue ) { _ -> imageLoader.memoryCache?.clear() imageLoader.diskCache?.clear() } }}
Recomposition Performance
Mark your data models @Immutable or @Stable to enable the Compose compiler to skip recomposition when inputs haven’t changed:
Kotlin
@Immutable// Tells Compose: all properties are val and of stable typesdataclassProductUiModel(val id: String,val name: String,val formattedPrice: String,val description: String,val imageUrl: String)// Without @Immutable, a data class with List<> properties will be inferred// as unstable by the Compose compiler, causing full recomposition of every// LazyColumn item on every parent recomposition - a major performance issue@ImmutabledataclassCartUiState(val items: List<CartItemUiModel>, // List<> requires @Immutable on the containing classval totalFormatted: String,val itemCount: Int)
Enable Compose compiler metrics to verify your composables are stable:
Kotlin
// In your app's build.gradle.ktscomposeCompiler { metricsDestination = layout.buildDirectory.dir("compose-metrics") reportsDestination = layout.buildDirectory.dir("compose-reports")}
Run ./gradlew assembleRelease and inspect the generated reports for unstable markers.
Web (Wasm) Performance Reality
Initial Wasm binary: 5–20MB depending on features used
Execution speed once loaded: faster than equivalent JavaScript, competitive with native apps for logic-heavy operations
Rendering: <canvas>-based — no DOM, no browser text selection, no SEO crawling, no browser accessibility tree
Not suitable for: SEO-dependent content, server-side rendering, or apps requiring native browser accessibility
Suitable for: Internal tools, dashboards, B2B applications where load time and SEO are not primary concerns
Testing Strategy Across Platforms
Unit Testing (commonTest)
Kotlin
// core/domain/src/commonTest/ — Pure logic tests run on all platformsclassProductListViewModelTest {privateval testProducts = listOf(Product(id = "1", name = "Widget", price = 9.99, description = "A widget", imageUrl = ""),Product(id = "2", name = "Gadget", price = 19.99, description = "A gadget", imageUrl = "") )@Testfun`loadProducts emits Success state with mapped UI models`() = runTest {val fakeRepository = FakeProductRepository(products = testProducts)val useCase = GetProductsUseCaseImpl(fakeRepository)val viewModel = ProductListViewModel(useCase)val state = viewModel.uiState.valueassertTrue(state is ProductListUiState.Success)assertEquals(2, (state as ProductListUiState.Success).products.size)assertEquals("$9.99", state.products[0].formattedPrice) }@Testfun`loadProducts emits Error state on network failure`() = runTest {val fakeRepository = FakeProductRepository(shouldFail = true)val useCase = GetProductsUseCaseImpl(fakeRepository)val viewModel = ProductListViewModel(useCase)val state = viewModel.uiState.valueassertTrue(state is ProductListUiState.Error)assertTrue((state as ProductListUiState.Error).isRetryable) }}// Fake repository - not a mock, avoids Mockito (JVM-only)classFakeProductRepository(privateval products: List<Product> = emptyList(),privateval shouldFail: Boolean = false) : ProductRepository {overridesuspendfungetProducts(): Result<List<Product>> = if (shouldFail) { Result.failure(NetworkException("Network unavailable")) } else { Result.success(products) }}
Do not use Mockito in commonTest — it is JVM-only. Use fakes (hand-written test doubles) or MockK’s multiplatform-compatible subset.
UI Testing
CMP UI tests use ComposeUiTest from compose-ui-test:
Sentry has the most mature KMP SDK with multiplatform crash reporting, breadcrumbs, and Kotlin/Native stack trace symbolication:
Kotlin
// composeApp/src/commonMain/ — Shared error reporting interfaceinterfaceErrorReporter {funcaptureException(throwable: Throwable, context: Map<String, String> = emptyMap())funaddBreadcrumb(category: String, message: String)funsetUser(userId: String)}// In your ViewModel base class:abstractclassBaseViewModel(protectedval errorReporter: ErrorReporter) : ViewModel() {protectedfunlaunchWithErrorHandling( block: suspendCoroutineScope.() -> Unit ) = viewModelScope.launch {try {block() } catch (e: CancellationException) {throw e // Never swallow CancellationException } catch (e: Exception) { errorReporter.captureException(e)handleError(e) } }protectedopenfunhandleError(e: Exception) {}}
Firebase Crashlytics does not have a native KMP SDK. You can integrate it via expect/actual where Android uses the Firebase SDK directly and iOS uses the Crashlytics iOS SDK called via Kotlin/Native interop — but setup is significantly more complex than Sentry.
// Will not compile — Android import in shared codeimport android.content.Context// Define an interface in commonMain, implement per platforminterfacePlatformContext// Marker interface or use Koin's module system
Pitfall 2: Using JVM-Only Libraries
Pitfall 3: Keyboard Insets on iOS
Kotlin
// Always use imePadding() for forms — handles iOS keyboard differently than Android@ComposablefunFormScreen() {Column( modifier = Modifier .fillMaxSize() .imePadding() // Pushes content above keyboard on both platforms .verticalScroll(rememberScrollState()) .padding(16.dp) ) {// Form fields }}
Note: On iOS, imePadding() behavior depends on the window configuration. Ensure your ComposeUIViewController is not configured with ignoresSafeArea(.keyboard) on the Swift side if you want CMP to handle keyboard insets. Choose one approach and apply it consistently.
Pitfall 4: Missing Coroutine Dispatcher Setup on iOS
Kotlin
// iosMain — MUST call this before any coroutine usage on iOS// Without it, Dispatchers.Main may not be properly initializedfuninitCoroutines() {// This is handled automatically when using lifecycle-viewmodel on iOS,// but if you use coroutines outside of ViewModels, explicit initialization// may be required depending on your kotlinx-coroutines-core version}
Ensure kotlinx-coroutines-core is in your iosMain dependencies (not just commonMain) to guarantee the Darwin dispatcher (iOS/macOS version of a Coroutine Dispatcher) is available.
Pitfall 5: Skipping Compose Compiler Metrics
Run the Compose compiler metrics on every release build and investigate any composables marked unstable. Unstable composables recompose unnecessarily, degrading performance silently.
Pitfall 6: Forgetting CancellationException
Kotlin
// Swallows coroutine cancellation — causes memory leaks and undefined behaviortry {val result = repository.getProducts()} catch (e: Exception) {handleError(e) // CancellationException caught here!}// Always rethrow CancellationExceptiontry {val result = repository.getProducts()} catch (e: CancellationException) {throw e // Must propagate} catch (e: Exception) {handleError(e)}
Migration Strategy from Native to CMP
Realistic Migration Path
Do not do a big-bang rewrite. Migrate incrementally with feature flags and measurable milestones.
Phase 0 — Foundation (Weeks 1–4)
Set up multi-module project structure
Migrate data models to commonMain
Migrate network layer (Ktor), serialization (kotlinx.serialization), and database (SQLDelight) to KMP
Set up DI (Koin) with platform modules
Establish CI pipeline building for Android and iOS from day one
Measure and baseline: build times, app startup time, binary size, crash rate
Phase 1 — First CMP Screen (Weeks 5–8)
Choose a low-risk, low-traffic screen (Settings, About, or a simple list)
Implement it in commonMain with full tests
Ship behind a feature flag — A/B test CMP vs native version
SharingStarted.WhileSubscribed used for all StateFlows
Back-swipe gesture tested on physical device
Font rendering reviewed on iOS (San Francisco vs Roboto differences)
imePadding() tested with all form screens on iPhone SE (smallest current screen)
Accessibility
All interactive elements have contentDescription or semantic roles
mergeDescendants = true applied to card-style components
TalkBack tested on Android (with CMP screen)
VoiceOver tested on iOS (acknowledge any known gaps)
Minimum touch target size: 48×48dp enforced
Performance
Compose compiler metrics reviewed — no unexpected unstable composables
LazyColumn scroll tested at 60fps on target minimum device specs
iOS startup time measured and within acceptable threshold
IPA size measured and within App Store guidelines
Testing
Unit tests in commonTest covering ViewModel state transitions
UI tests covering primary happy path and error state
Screenshot regression tests configured
CI builds both Android and iOS on every PR
Observability
Crash reporting integrated (Sentry recommended)
Structured logging in place
Performance metrics baseline captured
Who Is Using CMP in Production
JetBrains — Uses CMP in JetBrains Toolbox App and Fleet, with ongoing expansion.
Cash App (Block) — KMP used for shared business logic; CMP UI adoption in progress for select screens.
Touchlab — Consultancy with multiple enterprise deployments in healthcare, fintech, and retail; their public case studies are the most detailed available.
IceRock Development — Multiple production CMP deployments; maintains the moko suite of KMP/CMP libraries.
Yandex — Uses KMP for shared business logic in several products; CMP adoption expanding.
Recognized pattern across adopters: Teams that start with shared business logic via KMP report the lowest-risk path to CMP. Direct CMP adoption without prior KMP experience significantly increases migration risk.
Should Your Team Adopt CMP?
Adopt CMP if:
Your team writes Kotlin for Android and you maintain a parallel iOS codebase with feature parity requirements. The marginal cost of adopting CMP is very low; the long-term cost reduction is substantial.
You are starting a new project. The incremental cost of CMP vs Android-only is low, and you avoid the compounding technical debt of two separate UI codebases.
Your product serves non-accessibility-critical markets (B2B tools, internal apps, dashboards) where the iOS VoiceOver gap is manageable today.
You can invest in iOS-specific testing infrastructure from day one, not as an afterthought.
Proceed cautiously or defer if:
Your iOS app is in a regulated industry where WCAG 2.1 / ADA accessibility compliance is legally required. CMP’s iOS accessibility gaps are real and not fully controllable on your timeline.
Your app relies heavily on platform-specific animations, ARKit, Core ML on-device UI, or custom UIKit components that represent a significant portion of your UI surface.
Your team has no Kotlin experience. The KMP learning curve on top of CMP adoption simultaneously is a high-risk combination.
Your iOS app is a primary revenue driver and even a 200–300ms cold startup increase represents a measurable conversion loss at your scale — benchmark first.
The Right Default: Hybrid Approach
The most risk-managed production pattern today is:
Android: 100% CMP (builds on your existing Jetpack Compose investment)
iOS: CMP for data/logic-heavy screens; native SwiftUI for launch screen, onboarding, and accessibility-critical flows
Desktop: CMP if you need desktop support; low-cost add given Android CMP coverage
Web: CMP/Wasm for internal tools; native web (React/Vue) for consumer-facing, SEO-dependent products
This hybrid approach maximizes code reuse where CMP is strongest while using native where the gaps are most consequential.
Frequently Asked Questions
Q: Is Compose Multiplatform the same as Kotlin Multiplatform?
No. Kotlin Multiplatform (KMP) is the foundational technology for compiling Kotlin code to multiple targets and sharing business logic, data layers, and domain models across platforms. Compose Multiplatform (CMP) is built on top of KMP and specifically handles the declarative UI layer. You can use KMP without CMP (sharing logic while keeping native UI), but you cannot use CMP without KMP.
Q: Does CMP code run identically on all platforms?
No — and any resource that tells you it does is being imprecise. CMP code compiles and runs on all platforms, but font rendering, scroll physics, shadow appearance, gesture thresholds, and system behavior differ between Android and iOS because the rendering backends (Android’s hardware compositor vs Skia/Metal on iOS) operate differently. These differences require deliberate iOS testing and, in some cases, platform-specific composable implementations.
Q: How does CMP handle accessibility?
On Android, CMP’s accessibility support maps cleanly to Android’s Accessibility API — strong and production-ready. On iOS, CMP’s accessibility integration with UIAccessibility/VoiceOver has known gaps as of CMP 1.7.x. JetBrains is actively improving this. For iOS apps requiring full VoiceOver compliance today, a hybrid approach (native SwiftUI for accessibility-critical screens) is recommended.
Q: What is the realistic shared code percentage?
In production deployments, teams consistently achieve 65–80% shared UI code. The remaining 20–35% is platform-specific handling for: native view interop, platform lifecycle events, accessibility edge cases, and behaviors where native look-and-feel is non-negotiable. Claims of 90%+ shared code are technically possible for simple apps but are not representative of complex, production-quality applications.
Q: Does CMP support Material Design 3?
Yes. Material 3 (compose.material3) is fully supported in commonMain and renders on all platforms. The Material 3 component rendering on iOS is Skia-drawn (not native UIKit), which means it does not automatically adapt to iOS’s Human Interface Guidelines. If HIG compliance is required on iOS, you will need platform-specific theming via the expect/actual pattern or conditional logic using LocalPlatformInfo.
Q: How do I handle different screen sizes and form factors?
Use WindowSizeClass from compose-material3-adaptive, BoxWithConstraints, and responsive Modifier patterns — the same approach as Jetpack Compose on Android, applied in commonMain. These APIs are multiplatform-compatible.
Q: Is CMP free?
Yes. CMP is open-source under the Apache 2.0 license, free for commercial use. JetBrains monetizes through IntelliJ IDEA / Android Studio tooling and Kotlin-based services, not through CMP licensing.
Q: What is the binary size impact on iOS?
Adding CMP to an iOS app adds approximately 15–25MB uncompressed to the app bundle (including the Kotlin/Native runtime and Skia). After Apple’s App Thinning and compression, the incremental App Store download size increase is approximately 8–14MB. This is acceptable for most feature-rich applications; it may be a concern for lightweight utility apps competing on download size.
Conclusion
Compose Multiplatform is a production-viable framework for sharing UI code across Android, iOS, Desktop, and Web when adopted with clear eyes about its genuine tradeoffs.
Its real strengths: True Kotlin compilation (no bridges), zero retraining for Android Kotlin teams, first-class KMP integration, access to all native APIs via expect/actual and native view interop, and a strong trajectory backed by serious JetBrains investment.
Its real limitations today: iOS accessibility gaps requiring active management, startup overhead on iOS from Kotlin/Native runtime initialization, iOS debugging tooling significantly behind Android, and a Web/Wasm target still maturing toward production-grade use for consumer applications.
The teams shipping CMP successfully in production are not doing so because CMP eliminated all platform differences — they are doing so because they invested in proper architecture (Clean + MVI, typed state, state hoisting), iOS-specific testing, accessibility audits, and observability infrastructure. The framework enables code sharing; engineering discipline determines whether that sharing improves or degrades product quality.
Start with a well-scoped pilot. Measure relentlessly. Expand where the data supports it.
Modern Android apps run on phones, tablets, foldables, Chromebooks, and even desktop environments. If your layout only looks good on one screen size, users will notice.
This pattern is widely used in email apps, dashboards, and productivity tools.
Box vs BoxWithConstraints
You might wonder:
Why not just use Box?
Here’s the difference:
If you don’t need constraint info, stick with Box.
How It Aligns with Modern Android Best Practices
Google encourages:
Adaptive layouts
Multi-device support
Foldable readiness
BoxWithConstraints in Jetpack Compose supports all of this naturally.
It works well alongside:
Window size classes
Material 3 adaptive design
Large screen guidelines
You’re building future-ready UI when you use it correctly.
Quick FAQ
What is BoxWithConstraints in Jetpack Compose?
It is a layout composable that exposes parent layout constraints like maxWidth and maxHeight, allowing dynamic and responsive UI decisions during composition.
When should I use BoxWithConstraints?
Use it when your layout must change depending on available space, such as switching from column to row or adjusting grid columns.
Does BoxWithConstraints affect performance?
It can trigger recomposition when constraints change, but it is generally efficient when used correctly.
Is BoxWithConstraints better than LocalConfiguration?
If you’ve been diving into modern Android development, you’ve probably heard the buzz about Material Design 3 (also known as Material You) and Jetpack Compose. Today, we’re going to explore one of the most powerful yet underappreciated features that ties them together: Design Tokens.
Understanding Design Tokens in Material 3 and Jetpack Compose will transform how you build consistent, beautiful, and maintainable Android apps.
Let’s dive in..!
What Are Design Tokens?
Before we jump into the Material 3 specifics, let’s get on the same page about what design tokens actually are.
Think of design tokens as the DNA of your app’s design system. They’re named values that store design decisions like colors, typography, spacing, and shapes. Instead of hardcoding Color(0xFF6200EE) everywhere in your app, you’d use a token like MaterialTheme.colorScheme.primary.
Btw why this matters..?
Actually, when you decide to rebrand your app or support dark mode, you only need to change the token values in one place, not hunt down hundreds of hardcoded values scattered across your codebase.
Why Material Design 3 Changed Everything
Material Design 3 represents a massive evolution in how we think about design systems. Unlike Material Design 2, which had a more rigid structure, Material 3 introduces a flexible, personalized approach that adapts to user preferences.
Design Tokens in Material 3 and Jetpack Compose work together to make this personalization possible. Material 3 includes over 40 color tokens, dynamic color generation from wallpapers, and a comprehensive token system for typography, shapes, and elevation.
Understanding the Material Design 3 Token Structure
Material Design 3 organizes tokens into structured layers:
1. Reference Tokens
Raw values like colors or sizes.
Example:
Blue 500
16sp
8dp
2. System Tokens
Semantic values used by the UI system.
Example:
primary
onPrimary
surface
3. Component Tokens
Values applied to specific UI components.
Example:
Button container color
TextField label color
Jetpack Compose primarily exposes system tokens through MaterialTheme, which internally map to component behavior.
Material Design 3 in Jetpack Compose
Jetpack Compose provides the MaterialTheme composable (from the material3 library) that exposes design tokens:
colorScheme
typography
shapes
Let’s explore each with Kotlin examples.
The Core Components of Design Tokens in Material 3
Let’s break down the main categories of design tokens you’ll work with:
1. Color Tokens
Material 3’s color system is brilliant. Instead of just “primary” and “secondary,” you get a full palette that automatically handles light and dark modes, accessibility, and color harmonies.
Here, we’re accessing color tokens through MaterialTheme.colorScheme. The MaterialTheme composable provides access to Material 3’s design tokens. These tokens automatically adjust based on whether the user is in light or dark mode. The onPrimary token ensures text on your primary color is always readable.
2. Typography Tokens
Typography tokens define your text styles consistently across your app. Material Design 3 provides a complete type scale with tokens for everything from large display text to tiny labels.
Kotlin
@ComposablefunTypographyTokenExample() {Column( modifier = Modifier.padding(16.dp) ) {// Display large - for prominent textText( text = "Welcome Back!", style = MaterialTheme.typography.displayLarge )// Headline medium - for section headersText( text = "Your Dashboard", style = MaterialTheme.typography.headlineMedium )// Body large - for main contentText( text = "Here's a summary of your activity today.", style = MaterialTheme.typography.bodyLarge )// Label small - for captions or metadataText( text = "Last updated: 2 hours ago", style = MaterialTheme.typography.labelSmall ) }}
Each typography token (displayLarge, headlineMedium, bodyLarge, labelSmall) defines font size, weight, line height, and letter spacing. By using these Material 3 tokens instead of hardcoding text styles, your app maintains perfect typographic hierarchy.
3. Shape Tokens
Shapes define the corner radii and other geometric properties of your components. Material Design 3 uses different shape tokens for different component types.
Kotlin
@ComposablefunShapeTokenExample() {Row( modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) {// Extra small - for chips and small elementsSurface( shape = MaterialTheme.shapes.extraSmall, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(60.dp) ) {Box(contentAlignment = Alignment.Center) {Text("XS") } }// Medium - for cardsSurface( shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.size(60.dp) ) {Box(contentAlignment = Alignment.Center) {Text("M") } }// Large - for dialogs and sheetsSurface( shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.tertiaryContainer, modifier = Modifier.size(60.dp) ) {Box(contentAlignment = Alignment.Center) {Text("L") } } }}
Shape tokens (extraSmall, medium, large) ensure consistent corner radii throughout your app. Material 3 uses different shapes for different component types, creating visual cohesion and helping users understand component hierarchy.
Setting Up Design Tokens in Your Jetpack Compose Project
Now let’s get practical. Here’s how to implement Design Tokens in Material 3 and Jetpack Compose in your project.
Add Material 3 Dependency
First, ensure you have the Material 3 library in your build.gradle.kts file:
We’re defining two color schemes — one for light mode and one for dark mode. This follows the Material Design 3 color system specification. Each color has a specific purpose.
Notice the “on” prefix..? Those ensure text and icons are readable on their corresponding background colors.
Create Your Custom Theme
Now let’s wrap everything in a theme composable. This is where we configure the MaterialTheme composable with our Material 3 design tokens:
The MyAppTheme composable automatically detects if the system is in dark mode and switches between your light and dark color schemes. We pass our design tokens to the MaterialTheme composable, which makes them available throughout your app. We’re defining custom typography based on Material Design 3’s type scale while using Material 3’s default shapes.
Apply Your Theme
Wrap your app’s root composable with your theme:
Kotlin
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.material3.Surfaceimport androidx.compose.ui.ModifierclassMainActivity : ComponentActivity() {overridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {MyAppTheme {// Surface provides a background using the surface color tokenSurface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {// Your app content goes hereAppContent() } } } }}
By wrapping everything in MyAppTheme, all composables inside can access your Material 3 design tokens through MaterialTheme. The Surface composable uses the background color token automatically.
Advanced: Dynamic Color and Material You
One of the coolest features of Design Tokens in Material 3 and Jetpack Compose is dynamic color. On Android 12+, your app can generate its color scheme from the user’s wallpaper..!
This is the signature feature of Material You (Material Design 3’s brand name), creating truly personalized user experiences.
Kotlin
import android.os.Buildimport androidx.compose.material3.dynamicDarkColorSchemeimport androidx.compose.material3.dynamicLightColorSchemeimport androidx.compose.runtime.Composableimport androidx.compose.ui.platform.LocalContext@ComposablefunMyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, // Enable dynamic color content: @Composable () -> Unit) {val colorScheme = when {// Use dynamic colors on Android 12+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {val context = LocalContext.currentif (darkTheme) dynamicDarkColorScheme(context)elsedynamicLightColorScheme(context) }// Fall back to custom colors darkTheme -> DarkColorSchemeelse-> LightColorScheme }MaterialTheme( colorScheme = colorScheme, typography = AppTypography, content = content )}
On devices running Android 12 or higher, dynamicLightColorScheme() and dynamicDarkColorScheme() generate a complete Material 3 color scheme based on the user’s wallpaper. This creates a truly personalized experience without any extra work on your part! Your design tokens automatically adapt to the generated colors.
Creating Custom Design Tokens
Sometimes you need tokens beyond what Material 3 provides. Here’s how to extend the system while maintaining consistency with Material Design 3 principles:
We created custom spacing tokens using CompositionLocal, which allows us to provide values that can be accessed by any composable in the tree. The extension property makes accessing these tokens feel natural, just like accessing built-in Material 3 tokens. This approach maintains consistency with how Material Design 3 organizes its design system.
Best Practices for Design Tokens
Working with Design Tokens in Material 3 and Jetpack Compose effectively requires following some key principles:
Always Use Tokens, Never Hardcode
Bad:
Kotlin
Text( text = "Hello", color = Color(0xFF6750A4), // Hardcoded color fontSize = 16.sp // Hardcoded size)
Good:
Kotlin
Text( text = "Hello", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyLarge)
Use Semantic Token Names
When creating custom tokens, use names that describe the purpose, not the appearance. This follows Material Design 3’s semantic naming philosophy:
The onPrimary token adjusts automatically to maintain proper contrast ratio for accessibility, whether you’re in light mode, dark mode, or using dynamic colors. This is a core principle of Material Design 3’s accessibility-first approach.
Real-World Example: Building a Themed Card Component
Let’s put everything together with a practical example that showcases Design Tokens in Material 3 and Jetpack Compose:
Kotlin
import androidx.compose.foundation.layout.*import androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Favoriteimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dp@ComposablefunProductCard( title: String, description: String, price: String, onFavoriteClick: () -> Unit, modifier: Modifier = Modifier) {Card( modifier = modifier.fillMaxWidth(),// Using Material 3 shape token shape = MaterialTheme.shapes.medium,// Using Material 3 color tokens colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) {Column( modifier = Modifier.padding(16.dp) ) {Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) {// Using Material 3 typography tokenText( text = title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurfaceVariant )IconButton(onClick = onFavoriteClick) {Icon( imageVector = Icons.Default.Favorite, contentDescription = "Add to favorites", tint = MaterialTheme.colorScheme.primary ) } }Spacer(modifier = Modifier.height(8.dp))// Using Material 3 typography token for body textText( text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) )Spacer(modifier = Modifier.height(12.dp))// Using Material 3 typography token for priceText( text = price, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary ) } }}// Using the component@ComposablefunProductScreen() {MyAppTheme {Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {Column( modifier = Modifier .fillMaxSize() .padding(16.dp) ) {ProductCard( title = "Wireless Headphones", description = "Premium noise-cancelling headphones with 30-hour battery life.", price = "$299.99", onFavoriteClick = { /* Handle favorite */ } ) } } }}
This ProductCard component uses Material 3 design tokens exclusively. It automatically adapts to light/dark mode, respects dynamic colors from Material You, maintains proper typography hierarchy, and ensures all text is readable against its background. That’s the power of Material Design 3’s token-based system!
Testing Your Design Tokens
Want to make sure your Material 3 tokens work in all scenarios? Create a preview showcase:
Pro tip: Android Studio shows these previews side-by-side, letting you verify that your Material 3 design tokens create a cohesive experience in both light and dark modes.
Common Mistakes to Avoid
Mistake 1: Mixing Hardcoded and Token Values
Don’t do this:
Kotlin
Text( text = "Title", fontSize = 24.sp, // Hardcoded color = MaterialTheme.colorScheme.primary // Token)
Instead:
Kotlin
Text( text = "Title", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary)
Mistake 2: Forgetting About Accessibility
Always use “on” color tokens for text on colored backgrounds. Material Design 3 emphasizes accessibility:
Kotlin
// This might have poor contrastButton( colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiary, contentColor = Color.Gray // Bad! )) { Text("Submit") }// This ensures proper contrast following Material 3 guidelinesButton( colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiary, contentColor = MaterialTheme.colorScheme.onTertiary // Good! )) { Text("Submit") }
Mistake 3: Not Testing in Both Modes
Always preview your composables in both light and dark modes to ensure your Material 3 token usage works correctly.
This is why using design tokens with Material Design 3 in Jetpack Compose is strongly recommended.
Conclusion
Understanding and implementing Design Tokens in Material 3 and Jetpack Compose transforms your development workflow. You get:
Consistency: Every component uses the same Material Design 3 language
Maintainability: Change your entire theme by updating token values
Accessibility: Automatic contrast ratios and readability
Personalization: Dynamic colors that adapt to user preferences through Material You
Scalability: Easy to extend with custom tokens while maintaining Material 3 principles
The examples we’ve covered today give you a solid foundation to build beautiful, consistent Android apps following Material Design 3 guidelines. Start by implementing basic color and typography tokens, then gradually expand to custom tokens as your needs grow.
Remember, the key to mastering Design Tokens in Material 3 and Jetpack Compose is practice. Start refactoring your existing projects to use Material 3 tokens, and you’ll quickly see the benefits of this systematic approach.
Creating a polished Android app starts with one crucial decision: your app’s visual identity. If you’ve been wondering how to make your Jetpack Compose app look consistent and professional across every screen, you’re in the right place.
In this guide, I’ll walk you through building a Custom App Theme in Jetpack Compose using Material 3. Whether you’re building your first app or refining an existing one, you’ll learn how to create a theming system that’s both flexible and maintainable.
Why Material 3 Makes Custom Theming Easier
Material 3 (also called Material You) isn’t just another design system update. It’s Google’s most flexible theming framework yet, and it plays beautifully with Jetpack Compose.
Here’s what makes it special:
Dynamic color support — Your app can adapt to the user’s wallpaper colors (on Android 12+)
Improved design tokens — More granular control over colors, typography, and shapes Better accessibility — Built-in contrast and readability improvements
The best part..?
Once you set up your Custom App Theme in Jetpack Compose, Material 3 handles the heavy lifting of maintaining consistency throughout your app.
Understanding the Theme Building Blocks
Before we dive into code, let’s understand what makes up a theme in Jetpack Compose. Think of it like building a house — you need a solid foundation.
Your theme consists of three main pillars:
Color Scheme — All the colors your app uses
Typography — Font families, sizes, and weights
Shapes — Corner radiuses and component shapes
When these three work together harmoniously, your app feels intentional and polished.
Setting Up Your Project Dependencies
First things first — make sure you have the right dependencies in your build.gradle.kts file:
Material 3 provides 15 different text styles organized into five categories. This gives you flexibility while maintaining consistency in your Custom App Theme in Jetpack Compose:
Display — Hero text, splash screens
Headline — Page titles, section headers
Title — Card titles, dialog headers
Body — Paragraphs, main content
Label — Buttons, tabs, small UI elements
Defining Custom Shapes
Shapes add personality to your UI. From rounded corners to sharp edges, shapes influence how modern or traditional your app feels.
Create a Shape.kt file:
Kotlin
package com.yourapp.ui.themeimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.Shapesimport androidx.compose.ui.unit.dpval AppShapes = Shapes(// Extra small - chips, small buttons extraSmall = RoundedCornerShape(4.dp),// Small - buttons, text fields small = RoundedCornerShape(8.dp),// Medium - cards, dialogs medium = RoundedCornerShape(12.dp),// Large - bottom sheets, large cards large = RoundedCornerShape(16.dp),// Extra large - special components extraLarge = RoundedCornerShape(28.dp))
Shape usage tips:
Use extraSmall for chips and toggles
Use small for buttons and input fields
Use medium for cards and elevated surfaces
Use large for bottom sheets and modals
Use extraLarge for floating action buttons
You can also create asymmetric shapes or custom shapes using GenericShape for more creative designs.
Bringing It All Together: Your Theme Composable
Now comes the exciting part — assembling everything into your main theme composable. Update your Theme.kt file:
Dark theme detection — Automatically detects if the user prefers dark mode
Dynamic color support — On Android 12+, colors adapt to the user’s wallpaper
Status bar styling — Ensures the status bar matches your theme
Fallback colors — Uses your custom colors on older Android versions
This is the heart of your Custom App Theme in Jetpack Compose. Every screen that uses this theme will automatically have consistent colors, typography, and shapes.
Using Your Theme in the App
Now let’s see how to apply your theme to your app. In your MainActivity.kt:
Kotlin
package com.yourappimport android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.compose.foundation.layout.*import androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport androidx.compose.ui.tooling.preview.Previewimport androidx.compose.ui.unit.dpimport com.yourapp.ui.theme.AppThemeclassMainActivity : ComponentActivity() {overridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {AppTheme {Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {HomeScreen() } } } }}@ComposablefunHomeScreen() {Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) {// Using themed text stylesText( text = "Welcome to My App", style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary )Text( text = "This is a subtitle showing our custom typography", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface )Text( text = "Body text looks great with our custom font family. " +"Notice how everything feels cohesive and professional.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant )// Using themed button with custom shapesButton( onClick = { /* Handle click */ }, shape = MaterialTheme.shapes.medium ) {Text("Primary Button") }// Using themed cardCard( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer ) ) {Column(modifier = Modifier.padding(16.dp)) {Text( text = "Card Title", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimaryContainer )Spacer(modifier = Modifier.height(8.dp))Text( text = "This card uses our theme colors and shapes automatically.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer ) } } }}@Preview(showBackground = true)@ComposablefunHomeScreenPreview() {AppTheme {HomeScreen() }}
Key takeaways from this example:
Wrap your content in AppTheme { } to apply your custom theme
Access colors via MaterialTheme.colorScheme.primary (not hardcoded values!)
Access typography via MaterialTheme.typography.headlineLarge
Access shapes via MaterialTheme.shapes.medium
This approach ensures your Custom App Theme in Jetpack Compose is applied consistently throughout your app.
Creating Theme-Aware Components
Let’s build a custom component that respects your theme. This is where the real power of theming shines:
If you’re upgrading an existing app, here’s a quick migration guide:
Color Migration
Material 2 → Material 3:
primary → primary (similar)
primaryVariant → primaryContainer
secondary → secondary (similar)
secondaryVariant → secondaryContainer
background → background (same)
surface → surface (same)
Typography Migration
Material 3 has more granular typography styles. Map your old styles:
h1 → displayLarge
h2 → displayMedium
h3 → displaySmall
h4 → headlineLarge
h5 → headlineMedium
h6 → headlineSmall
subtitle1 → titleLarge
subtitle2 → titleMedium
body1 → bodyLarge
body2 → bodyMedium
Conclusion
Building a Custom App Theme in Jetpack Compose with Material 3 might seem complex at first, but it’s an investment that pays dividends. You get:
Consistency — Every screen automatically follows your design system
Maintainability — Change one value, update the entire app
Flexibility — Support light/dark themes and Material You effortlessly
Professionalism — Your app looks polished and well-crafted
Accessibility — Built-in contrast and readability standards
The key is starting with a solid foundation: well-defined colors, typography, and shapes. Once your theme is set up, building new screens becomes faster because you’re working with a consistent design language.
Remember, your Custom App Theme in Jetpack Compose isn’t set in stone. As your app evolves and your brand matures, you can refine your theme values. The beauty of this system is that those updates propagate throughout your entire app automatically.
When building apps with Jetpack Compose, you’ll often pass data down through multiple layers of composables. At first, this feels fine. But as your UI grows, you may find yourself passing the same parameter through five or six functions just to reach a deeply nested child.
That pattern is called prop drilling.
It works, but it clutters your APIs and makes your code harder to maintain.
This is where CompositionLocal in Jetpack Compose becomes incredibly useful. In this guide, we’ll learn what it is, when to use it, how it works under the hood, and how to avoid common mistakes.
What Is Prop Drilling in Jetpack Compose?
Prop drilling happens when you pass data through multiple composables, even though intermediate composables don’t use that data.
Any composable inside HomeScreen() can now access it.
Outside this block, the default value applies.
So basically,
What’s the provides keyword?
It’s an infix function that creates a ProvidedValue pairing your CompositionLocal with an actual value. Think of it as saying: “For this scope, LocalAppPrimaryColor provides Color.Green.”
You can even provide multiple values at once:
Kotlin
@ComposablefunMyApp() {val theme = AppTheme(/* ... */)val user = User(id = "123", name = "Anaya")CompositionLocalProvider( LocalAppTheme provides theme, LocalUser provides user ) {MainScreen() }}
Consume the Value
Now we access it using .current.
Kotlin
@ComposablefunHomeScreen() {val primaryColor = LocalAppPrimaryColor.currentText( text = "Welcome", color = primaryColor )}
That’s it.
No parameter passing.
No prop drilling.
How CompositionLocal in Jetpack Compose Works Internally
Understanding this improves your architectural decisions.
When you use CompositionLocal in Jetpack Compose, the value becomes part of the composition tree. Compose tracks reads of .current. If the value changes, only the composables that read it will recompose.
This makes it efficient.
It’s not like a global variable. It’s scoped and lifecycle-aware.
Using staticCompositionLocalOf
Use this when the value rarely or never changes. It’s more optimized but less flexible:
Kotlin
val LocalAppConfiguration = staticCompositionLocalOf {AppConfiguration(apiUrl = "https://api.softaai.com")}
When to use static? Only when the value is truly static for the entire composition, like build configuration or app constants.
compositionLocalOf Vs staticCompositionLocalOf
This is important.
compositionLocalOf
Tracks reads.
Causes recomposition when value changes.
Best for values that may change.
Example: dynamic theme.
staticCompositionLocalOf
Does NOT track reads.
Better performance.
Use when value will never change.
Example: app configuration object that stays constant.
Example:
Kotlin
val LocalAppConfig = staticCompositionLocalOf<AppConfig> {error("No AppConfig provided")}
Use this only when you are sure the value won’t change.
Real-World Example: Building a Theme System
Let’s build a complete example that shows the power of CompositionLocal in Jetpack Compose. We’ll create a theme system with light and dark modes:
// Step 5: Use theme throughout the app@ComposablefunMainContent(modifier: Modifier = Modifier) {val theme = LocalAppTheme.currentColumn( modifier = modifier .fillMaxSize() .background(theme.colors.background) .padding(16.dp) ) {Text( text = "Welcome!", style = theme.typography.heading, color = theme.colors.text )Spacer(modifier = Modifier.height(16.dp))// This composable also has access to the themeProfileCard() }}@ComposablefunProfileCard() {val theme = LocalAppTheme.currentCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = theme.colors.surface ) ) {Column(modifier = Modifier.padding(16.dp)) {Text( text = "User Profile", style = theme.typography.heading, color = theme.colors.primary )Text( text = "This card automatically updates with the theme!", style = theme.typography.body, color = theme.colors.text ) } }}
Notice how ProfileCard doesn’t need to receive the theme as a parameter. It simply accesses LocalAppTheme.current and gets the value. When you toggle between light and dark mode, all composables that read from LocalAppTheme automatically recompose with the new values.
That’s the power of CompositionLocal in Jetpack Compose.
Best Practices for Using CompositionLocal
1. Use It Sparingly
CompositionLocal is powerful, but don’t overuse it. It’s perfect for:
Application-wide themes
User authentication state
Locale/language settings
Navigation controllers
Dependency injection
It’s NOT ideal for:
Component-specific state
Data that changes frequently at the component level
Communication between sibling composables
2. Always Provide Default Values
Always include a sensible default in your compositionLocalOf lambda:
Kotlin
val LocalUser = compositionLocalOf {User(id = "123", name = "Anaya", isAuthenticated = false)}
This prevents crashes if someone forgets to provide a value and makes your code more robust.
3. Make CompositionLocals Top-Level Properties
Define them at the file level, not inside composables:
Kotlin
// Good - Top levelval LocalAnalytics = compositionLocalOf { AnalyticsTracker() }@ComposablefunMyScreen() {// Bad - Inside composableval LocalSomething = compositionLocalOf { /* ... */ }}
4. Use Descriptive Names with “Local” Prefix
This convention makes it immediately clear that you’re dealing with a CompositionLocal:
Add KDoc comments to explain what the CompositionLocal provides and when to use it:
Kotlin
/** * Provides the current app theme (colors, typography, spacing). * This value updates when the user switches between light and dark mode. */val LocalAppTheme = compositionLocalOf {AppTheme(/* ... */)}
Common Pitfalls and How to Avoid Them
Even sometimes experienced developers make mistakes with CompositionLocal in Jetpack Compose. Here are the most common issues:
Pitfall 1: Reading CompositionLocal in Non-Composable Context
Kotlin
// Wrong - Can't use .current outside a composableclassMyViewModel {val theme = LocalAppTheme.current // Compilation error!}// Correct - Pass it as a parameter if needed@ComposablefunMyScreen(viewModel: MyViewModel) {val theme = LocalAppTheme.current viewModel.updateTheme(theme)}
Pitfall 2: Creating New Instances on Every Recomposition
Kotlin
@ComposablefunMyApp() {// Bad - Creates new theme on every recompositionCompositionLocalProvider( LocalAppTheme provides AppTheme(/* ... */) ) {Content() }}@ComposablefunMyApp() {// Good - Remember the themeval theme = remember {AppTheme(/* ... */) }CompositionLocalProvider(LocalAppTheme provides theme) {Content() }}
Pitfall 3: Using CompositionLocal for Frequent Updates
Kotlin
// Not ideal - Mouse position changes too frequentlyval LocalMousePosition = compositionLocalOf { Offset.Zero }// Better - Use State or pass as parameter@ComposablefunTrackingCanvas() {var mousePosition byremember { mutableStateOf(Offset.Zero) }// Use mousePosition directly}
Pitfall 4: Forgetting to Provide a Value
If you forget to provide a value, you’ll get the default. This might be okay, or it might be a bug:
Kotlin
val LocalUser = compositionLocalOf<User?> { null }@ComposablefunMyApp() {// Forgot to provide a user!MainScreen()}@ComposablefunMainScreen() {val user = LocalUser.current // Will be nullText("Hello, ${user?.name}") // Displays "Hello, null"}
Testing Composables with CompositionLocal
When testing composables that rely on a CompositionLocal, you should provide a value using CompositionLocalProvider if the composable depends on that value and no suitable default exists. This allows you to override environment values and test different scenarios.
This approach lets you test your composables with different CompositionLocal values, ensuring they work correctly in all scenarios.
CompositionLocal vs. Other State Management Solutions
You might wonder when to use CompositionLocal in Jetpack Compose versus other state management approaches. Here’s a quick guide:
Use CompositionLocal when:
Data is needed by many composables across the tree
The data represents ambient context (theme, locale, user)
You want to avoid prop drilling
The data changes infrequently
Use State/ViewModel when:
Data is specific to a screen or feature
You need business logic tied to the data
The data changes frequently
You need to survive configuration changes
Use Passed Parameters when:
Only a few composables need the data
The relationship is direct parent-child
You want explicit data flow
Often, the best solution combines these approaches. For example, you might use CompositionLocal for the theme, ViewModels for business logic, and parameters for component-specific props.
FAQ’s
What is CompositionLocal in Jetpack Compose?
CompositionLocal in Jetpack Compose is a mechanism to implicitly pass data down the composable tree without manually passing parameters through every function.
How does CompositionLocal avoid prop drilling?
It provides scoped values that child composables can access directly, eliminating the need to pass the same parameter through multiple intermediate composables.
When should you use CompositionLocal?
Use it for shared, cross-cutting concerns such as themes, configuration, context, or localization. Avoid using it for regular screen state.
Conclusion
Prop drilling isn’t always wrong. But when your composable tree gets deep, it becomes frustrating.
CompositionLocal in Jetpack Compose gives you a clean, structured way to share data across your UI without cluttering every function signature.
Use it thoughtfully.
Keep your dependencies clear.
And treat it as a tool for environmental data, not a replacement for proper state management.
When applied correctly, it makes your Compose code cleaner, more scalable, and easier to reason about.
If you’re building modern Android apps with Kotlin and Jetpack Compose, mastering CompositionLocal is not optional. It’s part of writing professional-level Compose code.
Have you ever wondered how Android apps magically match your wallpaper colors? Or how Material Design creates those perfectly harmonious color palettes that just work?
That’s the magic of Material 3’s dynamic color system, and today, we’re diving deep into how it all comes together using Kotlin and Jetpack Compose.
By the end of this guide, you’ll understand exactly how the Material 3 colorScheme works, how to implement it in your Android apps with Kotlin, and how to harness the power of dynamic theming to create stunning user interfaces that feel personal and cohesive.
Let’s get started..!
What Is Material 3 ColorScheme?
The Material 3 colorScheme is Google’s revolutionary approach to app theming that goes way beyond simple primary and secondary colors. Think of it as a complete color system that automatically generates a harmonious palette of colors designed to work together beautifully.
Here’s what makes it special:
Dynamic Color Generation: Instead of manually picking dozens of color shades, the Material 3 colorScheme generates an entire palette from a single seed color. This means you get consistent, accessible, and visually appealing colors without the guesswork.
Adaptive Theming: The system automatically adjusts for light and dark modes, ensuring your app looks great in any setting.
Wallpaper Integration: On Android 12 and above, your app can automatically extract colors from the user’s wallpaper, creating a truly personalized experience.
Accessibility Built-In: Every color in the Material 3 colorScheme meets WCAG (Web Content Accessibility Guidelines) accessibility standards when used correctly, so you don’t have to worry about contrast ratios.
Understanding Dynamic Color: The Foundation
Dynamic color is the heart of Material 3 on Android. But what exactly is it?
Imagine your phone’s wallpaper is a beautiful sunset with warm orange and purple tones. With dynamic color, your apps can extract those colors and build their entire theme around them. It’s personalization taken to the next level.
How Dynamic Color Works on Android
The process is actually quite elegant:
Color Extraction: The system analyzes your wallpaper using the Monet color system
Palette Generation: Using color science algorithms, it creates a full tonal palette
Role Assignment: Colors are assigned to specific UI roles (more on this shortly)
Adaptation: The scheme automatically adapts for light and dark themes
The beauty of the Material 3 colorScheme is that all this complexity is handled for you in Jetpack Compose. You just need to understand how to use it.
The Color Roles: Your New Best Friends
Here’s where Material 3 gets really interesting. Instead of thinking in terms of “primary,” “secondary,” and “tertiary” colors alone, the Material 3 colorScheme introduces color roles.
Think of color roles as jobs that colors perform in your UI. Let’s break down the main players:
Primary Colors
Primary: This is your brand color, the star of the show. It appears on prominent buttons and active states.
OnPrimary: Text and icons that sit on top of the primary color. The Material 3 colorScheme ensures this has enough contrast to be readable.
PrimaryContainer: A lighter (or darker in dark mode) version used for less prominent components.
OnPrimaryContainer: Text that appears on primary containers.
Secondary Colors
Secondary: Provides visual variety and highlights less prominent components.
OnSecondary: You guessed it — text on secondary colors.
SecondaryContainer: For chips, cards, and other contained elements.
OnSecondaryContainer: Text on those containers.
Tertiary Colors
Tertiary: Adds even more variety, often used for accents and special highlights.
OnTertiary, TertiaryContainer, OnTertiaryContainer: Following the same pattern.
Surface and Background Colors
Surface: The background color for cards, sheets, and menus.
OnSurface: Text and icons on surfaces.
SurfaceVariant: Alternative surface with subtle differences.
OnSurfaceVariant: Text with lower emphasis.
Background: The main background of your app.
OnBackground: Primary text on the background.
Error Colors
Error: For error states and destructive actions.
OnError: Text on error colors.
ErrorContainer: Background for error messages.
OnErrorContainer: Text in error containers.
Special Roles
Outline: Borders and dividers.
OutlineVariant: Subtle borders.
Scrim: Semi-transparent overlays.
InverseSurface, InverseOnSurface, InversePrimary: For high-contrast elements like tooltips.
SurfaceTint: Used for elevation overlays in Material 3.
Phew! That’s a lot of colors, right..?
But here’s the magic: the Material 3 colorScheme generates all of these automatically, ensuring they work harmoniously together.
In short, a typical Material 3 colorScheme includes:
primary – main brand color
onPrimary – content placed on primary
secondary – supporting color
tertiary – optional accent color
background – app background
surface – cards and sheets
error – error states
onSurface – text/icons on surfaces
Each color has a paired onColor to guarantee readability.
This pairing is key to accessibility.
Setting Up Your Kotlin Project
Before we dive into code, let’s make sure your Android project is ready for Material 3.
Adding Dependencies
First, add the necessary dependencies to your build.gradle.kts (Module level):
Kotlin
dependencies {// Jetpack Compose BOM (Bill of Materials)implementation(platform("androidx.compose:compose-bom:2024.02.00"))// Material 3implementation("androidx.compose.material3:material3")// Other Compose dependenciesimplementation("androidx.compose.ui:ui")implementation("androidx.compose.ui:ui-tooling-preview")implementation("androidx.activity:activity-compose:1.8.2")// Core KTXimplementation("androidx.core:core-ktx:1.12.0")// Lifecycleimplementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")}
Problem: Compose previews show incorrect or default colors.
Solution: Always wrap preview content in your theme:
Kotlin
@Preview@ComposablefunMyComposablePreview() {MyAppTheme { // Never forget the theme wrapper!MyComposable() }}
Issue 4: System Bars Not Matching Theme
Problem: Status bar and navigation bar don’t match app theme.
Solution: Update system bars in your theme composable:
Kotlin
val view = LocalView.currentif (!view.isInEditMode) {SideEffect {val window = (view.context as Activity).window// Set status bar color window.statusBarColor = colorScheme.surface.toArgb()// Set navigation bar color window.navigationBarColor = colorScheme.surface.toArgb()// Adjust icon colors WindowCompat.getInsetsController(window, view).apply { isAppearanceLightStatusBars = !darkTheme isAppearanceLightNavigationBars = !darkTheme } }}
Issue 5: Gradle Build Errors
Problem: Cannot resolve Material 3 symbols.
Solution: Ensure you have the correct dependencies:
Kotlin
dependencies {// Use BOM for version managementimplementation(platform("androidx.compose:compose-bom:2024.02.00"))implementation("androidx.compose.material3:material3")// Or specify version explicitlyimplementation("androidx.compose.material3:material3:1.2.0")}
val colorScheme = MaterialTheme.colorScheme// Primary colorscolorScheme.primary // Main brand colorcolorScheme.onPrimary // Text on primarycolorScheme.primaryContainer // Lighter primary variantcolorScheme.onPrimaryContainer // Text on primary container// Secondary colorscolorScheme.secondary // Secondary accentcolorScheme.onSecondary // Text on secondarycolorScheme.secondaryContainer // Lighter secondarycolorScheme.onSecondaryContainer // Text on secondary container// Tertiary colorscolorScheme.tertiary // Third accent colorcolorScheme.onTertiary // Text on tertiarycolorScheme.tertiaryContainer // Lighter tertiarycolorScheme.onTertiaryContainer // Text on tertiary container// Surface and backgroundcolorScheme.surface // Card/sheet backgroundcolorScheme.onSurface // Text on surfacescolorScheme.surfaceVariant // Alternative surfacecolorScheme.onSurfaceVariant // Text on surface variantcolorScheme.background // Main app backgroundcolorScheme.onBackground // Text on background// Error colorscolorScheme.error // Error state colorcolorScheme.onError // Text on errorcolorScheme.errorContainer // Error message backgroundcolorScheme.onErrorContainer // Text in error messages// Utility colorscolorScheme.outline // Borders and dividerscolorScheme.outlineVariant // Subtle borderscolorScheme.surfaceTint // Elevation tint (usually primary)colorScheme.scrim // Semi-transparent overlays// Inverse colors (for tooltips, snackbars)colorScheme.inverseSurface // High-contrast surfacecolorScheme.inverseOnSurface // Text on inverse surfacecolorScheme.inversePrimary // Primary color on inverse surface
The Material 3 colorScheme is a game-changer for Android app theming. It takes the complexity out of color design and gives you a robust, accessible, and beautiful color system right out of the box.
Here’s what we’ve covered:
Understanding: The Material 3 colorScheme generates complete, harmonious palettes from seed colors or wallpaper
Color Roles: Semantic color names ensure accessibility and visual consistency
Implementation: Simple Kotlin setup with Jetpack Compose and dynamic color support
Dynamic Color: Apps automatically match user wallpapers on Android 12+ for personalized experiences
Best Practices: Use semantic colors, test both themes, provide fallbacks, and optimize performance
Real Examples: Production-ready Kotlin code you can use in your projects immediately
Troubleshooting: Solutions to common issues developers face
The beauty of the Material 3 colorScheme is that it makes professional color design accessible to everyone. You don’t need to be a color theory expert to create stunning, accessible Android apps.
Getting Started Today
Start your next Android project by:
Adding Dependencies: Include Material 3 in your build.gradle.kts
Creating Your Theme: Set up lightColorScheme() and darkColorScheme() in Theme.kt
Enabling Dynamic Color: Support Android 12+ wallpaper theming
Using Semantic Colors: Reference MaterialTheme.colorScheme throughout your composables
Testing Thoroughly: Preview in both light and dark modes
The Material 3 colorScheme handles the complexity of color science, accessibility, and harmonization so you can focus on building amazing user experiences. And that’s what makes it so powerful.
If you’re building Android apps with Jetpack Compose, chances are you’ve already used Compose Preview. Or at least clicked the little Preview tab in Android Studio and hoped it would magically show your UI.
Sometimes it does. Sometimes it doesn’t.
In this blog, we’ll break down Compose Preview, covering everything from core mechanics to practical tips. You’ll learn:
What Compose Preview actually is
How it works under the hood
Why it matters for real-world development
Where it struggles and why
When to trust it and when not to
Let’s start with the basics.
What Is Compose Preview?
Compose Preview is a design-time tool in Android Studio that lets you see your Jetpack Compose UI without running the app on a device or emulator.
It renders composable functions directly inside the IDE.
That means:
Faster feedback
No APK install
No waiting for Gradle every time you tweak padding or text size
In short, Compose Preview helps you design UI faster.
@Preview tells Android Studio: Render this composable
showBackground = true adds a white background so text is readable
GreetingPreview() supplies sample data ("Android")
This preview function is not used in production. It exists only for design-time visualization.
That’s an important detail many beginners miss.
How Compose Preview Works Behind the Scenes
Compose Preview does not run your full app.
Instead, Android Studio:
Compiles the composable function
Runs it in a special design-time environment
Skips most Android framework components
Renders the UI using sample data
That’s why previews are fast.
And that’s also why they’re limited.
Why Compose Preview Matters So Much
1. Faster UI Iteration
With Compose Preview, you can:
Adjust spacing
Change colors
Try different text styles
Experiment with layouts
All without touching an emulator.
For UI-heavy screens, this saves hours over time.
2. Encourages Smaller, Cleaner Composables
Compose Preview works best with small, focused composables.
That naturally pushes you toward:
Better separation of concerns
Reusable UI components
Clearer code structure
This directly improves long-term maintainability.
3. Better Design Collaboration
Designers and developers can:
Review UI changes quickly
Compare states side by side
Validate layouts early
Compose Preview becomes a shared visual language.
Advanced Compose Preview Features You Should Know
Beyond basic previews, several advanced features make Compose Preview even more powerful.
Preview with Different Device Configurations
The @Preview annotation accepts parameters that let you simulate different devices, screen sizes, and system settings.
Kotlin
@Preview( name = "Small phone", device = Devices.PIXEL_3A, showSystemUi = true)@Preview( name = "Large phone", device = Devices.PIXEL_7_PRO, showSystemUi = true)@Preview( name = "Tablet", device = Devices.PIXEL_TABLET, showSystemUi = true)@Preview( name = "Foldable", device = Devices.FOLDABLE, showSystemUi = true)@Preview( name = "Landscape", device = Devices.PIXEL_7_PRO, widthDp = 891, heightDp = 411)@Preview( name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)@Preview(showBackground = true)@ComposablefunResponsiveLayoutPreview() {MaterialTheme {Surface( modifier = Modifier.fillMaxSize(), tonalElevation = 4.dp ) {Column( modifier = Modifier .fillMaxSize() .padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) {// HeaderText( text = "Responsive UI", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold )Text( text = "Adaptive layouts across form factors", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant )Divider()Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) {FeatureRow("Phones", "Compact & large screens")FeatureRow("Tablets", "Expanded content layouts")FeatureRow("Foldables", "Posture-aware UI")FeatureRow("Themes", "Light & Dark mode ready") } } } }}@ComposableprivatefunFeatureRow( title: String, subtitle: String) {Column {Text( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold )Text( text = subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) }}
Let me break down what’s happening here:
device = Devices.PIXEL_7_PRO: This tells Compose Preview to render your composable as if it’s running on a Pixel 7 Pro device, matching that specific screen size and dimensions.
showSystemUi = true: This parameter displays the system UI elements like the status bar and navigation bar, giving you a more realistic preview of how your app will look.
uiMode = Configuration.UI_MODE_NIGHT_YES: This simulates dark mode, letting you verify that your colors and themes work properly in both light and dark settings.
You can stack multiple @Preview annotations on the same function to see all these variations simultaneously.
Preview Parameters for Dynamic Content
Sometimes you want to test your composables with different data sets. The @PreviewParameter annotation helps with this.
Kotlin
classUserStateProvider : PreviewParameterProvider<Boolean> {overrideval values = sequenceOf(true, false)}@Preview(showBackground = true)@ComposablefunStatusBadgePreview(@PreviewParameter(UserStateProvider::class) isActive: Boolean) {Box( modifier = Modifier .size(100.dp) .background( color = if (isActive) Color.Green else Color.Red, shape = CircleShape ), contentAlignment = Alignment.Center ) {Text( text = if (isActive) "Active"else"Inactive", color = Color.White, fontWeight = FontWeight.Bold ) }}
Here,
The UserStateProvider class implements PreviewParameterProvider<Boolean>, which means it provides a sequence of Boolean values for previewing. The values property returns both true and false.
When you use @PreviewParameter(UserStateProvider::class) on the isActive parameter, Compose Preview automatically generates two separate previews—one for each value in the sequence. You get both the active and inactive states without writing separate preview functions.
This approach is incredibly useful when testing with lists of data, different user types, or various configuration options.
Interactive Preview Mode
Recent versions of Android Studio introduced interactive preview mode, which lets you click buttons, scroll lists, and interact with your UI directly in the preview pane. This feature brings you even closer to the actual app experience without leaving the IDE.
To enable it, look for the interactive mode toggle in the preview pane toolbar. Keep in mind that interactions are limited to the composable being previewed — you can’t navigate to other screens or trigger real network calls.
Where Compose Preview Falls Short
Compose Preview is helpful, but it’s not perfect.
Let’s talk honestly about its limitations.
1. No Real Runtime Logic
Compose Preview does not handle:
Network calls
Database access
ViewModel state from real sources
Dependency injection (Hilt, Koin)
If your composable depends on runtime data, preview will break.
That’s why preview-friendly composables should take simple, deterministic parameters that can be easily mocked in previews, rather than ViewModels.
2. Limited Interaction Support
You can’t:
Click buttons meaningfully
Trigger navigation
Test animations properly
Simulate gestures accurately
Compose Preview shows how things look, not how they behave.
For behavior, you still need:
Emulators
Physical devices
UI tests
3. Can Be Slow in Large Projects
As your project grows:
Previews may take longer to render
IDE memory usage increases
Sometimes previews just refuse to refresh
This isn’t your fault. It’s a known trade-off.
4. Not a Replacement for Testing
Compose Preview is not a test.
It won’t catch:
Crashes
Logic bugs
Edge-case states
Performance issues
Think of it as a design aid, not a quality gate.
Best Practices for Using Compose Preview
To get the most out of Compose Preview:
Keep Preview Functions Simple and Focused
Your preview functions should be straightforward and serve a single purpose. Don’t overcomplicate them with business logic or complex data transformations.
Kotlin
// Good: Simple and clear@Preview(showBackground = true)@ComposablefunLoadingButtonPreview() {LoadingButton( text = "Loading", isLoading = true, onClick = { } )}// Avoid: Too much logic in preview@Preview(showBackground = true)@ComposablefunComplicatedPreview() {val viewModel = remember { MyViewModel() }val state by viewModel.uiState.collectAsState()// This won't work well in preview..!}
The first preview is clean and predictable. The second tries to instantiate a ViewModel, which likely depends on dependency injection, context, or other resources that aren’t available in preview mode.
Use Preview Groups for Organization
When you have many related previews, organize them into preview groups for better navigation.
By creating a custom annotation like @ComponentPreviews and applying it alongside your @Preview annotations, you can filter and group previews in Android Studio. This becomes invaluable when working on large projects with hundreds of composables.
Create Preview Fixtures for Common Data
Maintain a separate file with preview fixtures — sample data objects you can reuse across multiple previews.
Kotlin
// PreviewFixtures.ktobjectPreviewFixtures {val sampleUser = UserData( name = "Amol Pawar", email = "[email protected]", joinDate = "March 2022" )val sampleMessages = listOf(MessageData("Hello there!", "Amol", "9:00 AM"),MessageData("How are you?", "Rutuja", "9:05 AM"),MessageData("Doing great!", "Amol", "9:10 AM") )val longText = """ This is a longer text sample that helps us test how our UI handles content that spans multiple lines. It's useful for checking text wrapping, overflow behavior, and spacing. """.trimIndent()}
These edge case previews help you catch layout issues before they reach production. Does your text truncate properly? Do your empty states look intentional rather than broken?
The Future of Compose Preview
The Compose Preview tool continues to evolve with each Android Studio release. Recent improvements include better performance, enhanced animation support, and more sophisticated interactive capabilities.
Looking ahead, we can expect:
Deeper integration with design tools: Better collaboration between designers and developers through improved Figma integration and design token support.
AI-assisted previews: Automated generation of preview functions based on your composable parameters and common usage patterns.
Enhanced debugging: More powerful inspection tools for understanding why your UI renders the way it does.
Cloud-based previews: The ability to share interactive previews with team members without requiring them to open Android Studio.
The Android development community actively shapes these improvements through feedback, so don’t hesitate to file feature requests or bug reports.
Conclusion
Despite its limitations, Compose Preview is an essential part of modern Android development. The speed and convenience it offers make it ideal for rapid UI iteration and component-level design work.
The key is knowing when to use it. Compose Preview works best for visual validation and layout refinement, while emulators or real devices are still necessary for testing interactions, animations, and real data flows.
When used with preview-friendly composables and best practices, Compose Preview significantly improves development speed and feedback. It turns UI work into a more iterative, design-driven process rather than a cycle of long builds and guesswork.
If you’ve ever opened your beautifully designed app on a tablet only to see it looking like a stretched-out phone screen, you know the pain. Or maybe you’ve watched your carefully crafted layout break apart on a foldable device. Trust me, I’ve been there.
The good news..? Responsive and Adaptive UI in Jetpack Compose makes handling multiple screen sizes way easier than the old XML days. Today, I’m going to walk you through everything you need to know to make your apps look fantastic on every device, from tiny phones to massive tablets.
Let’s dive in!
What’s the Difference Between Responsive and Adaptive UI?
Before we jump into code, let’s clear up some confusion. These terms get thrown around a lot, but they mean different things.
Responsive UI is like water — it flows and adjusts smoothly to fit any container. Your layout stretches, shrinks, and rearranges based on available space. Think of a text field that grows wider on a tablet or a grid that shows more columns on larger screens.
Adaptive UI is more like having different outfits for different occasions. Your app actually changes its structure based on the device. On a phone, you might show a single-pane layout. On a tablet, you’d show a master-detail view with two panes side by side.
In real-world apps, you’ll use both approaches together. That’s what makes Responsive and Adaptive UI in Jetpack Compose so powerful.
Why Jetpack Compose Makes This Easier
If you’ve built responsive layouts in XML, you know it can get messy fast. Multiple layout files, qualifiers, configuration changes — it’s a lot to manage.
Jetpack Compose changes the game. Everything is code-based, which means you can use regular Kotlin logic to make decisions about your UI. No more jumping between files. No more cryptic folder names like layout-sw600dp-land.
Plus, Compose gives you real-time information about screen size, orientation, and window metrics. You can make smart decisions on the fly.
Setting Up Your Project
First things first. Make sure you have the necessary dependencies in your build.gradle.kts file:
The material3-window-size-class library is your best friend for building Responsive and Adaptive UI in Jetpack Compose. It provides a standardized way to categorize screen sizes.
Understanding Window Size Classes
Window size classes are Google’s recommended way to handle different screen sizes. Instead of checking exact pixel dimensions, you work with three categories: Compact, Medium, and Expanded.
Here’s what they mean:
Compact: Most phones in portrait mode (width < 600dp)
Medium: Most phones in landscape, small tablets, or foldables (600dp ≤ width < 840dp)
Expanded: Large tablets and desktops (width ≥ 840dp)
Let’s see how to get the current window size class:
Kotlin
@ComposablefunMyResponsiveApp() {val windowSizeClass = calculateWindowSizeClass(activity = thisas Activity)when (windowSizeClass.widthSizeClass) { WindowWidthSizeClass.Compact -> {// Show phone layoutCompactLayout() } WindowWidthSizeClass.Medium -> {// Show tablet or landscape phone layoutMediumLayout() } WindowWidthSizeClass.Expanded -> {// Show large tablet or desktop layoutExpandedLayout() } }}
Here, we’re calculating the window size class and using a when statement to decide which layout to show. Simple, right..? This is the foundation of adaptive UI.
You can also check height size classes the same way using windowSizeClass.heightSizeClass. This is super useful for handling landscape orientations.
Building Your First Responsive Layout
Let’s start with something practical — a responsive grid that adjusts the number of columns based on screen size.
We’re creating a grid that shows 2 columns on phones, 3 on medium devices, and 4 on large tablets. The LazyVerticalGrid handles the layout, and we’re using GridCells.Fixed() to set the column count.
The aspectRatio(1f) modifier makes each card square, which looks clean and consistent across all screen sizes. The spacing and padding ensure everything breathes nicely.
Creating Adaptive Navigation
Navigation is where adaptive UI really shines. On phones, you typically use a bottom navigation bar or navigation drawer. On tablets, a persistent navigation rail makes better use of space.
On compact screens (phones), we use NavigationBar at the bottom. On medium and expanded screens, we use NavigationRail on the side. The useNavigationRail boolean makes this decision.
The Row layout for tablets puts the navigation rail on the left and gives the content area the remaining space with weight(1f). Clean and efficient!
Master-Detail Pattern for Tablets
The master-detail pattern is the gold standard for tablet layouts. You show a list on the left and details on the right. On phones, you navigate between these screens.
On phones, we show either the list or the detail screen — never both. When an item is selected, we navigate to the detail view and provide a back button.
On tablets, both panes are visible simultaneously. The list takes 40% of the width (weight(0.4f)), and the detail pane takes 60% (weight(0.6f)). Clicking an item updates the detail pane without any navigation.
This is Responsive and Adaptive UI in Jetpack Compose at its finest!
Using BoxWithConstraints for Fine-Grained Control
Sometimes you need more precise control over your layout based on exact dimensions. That’s where BoxWithConstraints comes in handy.
Why is this useful?BoxWithConstraints gives you the exact constraints (min/max width and height) of your composable. You can make pixel-perfect decisions about your layout.
The content inside BoxWithConstraints recomposes whenever the constraints change, like when the device rotates or the window is resized. This makes it perfect for Responsive and Adaptive UI in Jetpack Compose.
Responsive Typography and Spacing
Don’t forget about text sizes and spacing! What looks good on a phone might be tiny on a tablet.
The concept: Larger screens can handle bigger text and more generous spacing. This code adjusts both based on the window size class, creating a more comfortable reading experience on every device.
Handling Configuration Changes Gracefully
One beautiful thing about Compose is that it handles configuration changes automatically. When the device rotates or folds, your composables recompose with the new window size class.
But you need to manage state properly:
Kotlin
@ComposablefunResponsiveApp() {val windowSizeClass = calculateWindowSizeClass(activity = LocalContext.current as Activity)// State survives configuration changes with rememberSaveablevar selectedTab byrememberSaveable { mutableStateOf(0) }var selectedItem byrememberSaveable { mutableStateOf<Item?>(null) }AdaptiveScaffold( windowSizeClass = windowSizeClass, selectedTab = selectedTab, onTabSelected = { selectedTab = it }, selectedItem = selectedItem, onItemSelected = { selectedItem = it } ) {// Your content here }}
Key point: Use rememberSaveable instead of remember for state that should survive configuration changes. This ensures your selected tab or item doesn’t reset when the user rotates their device.
Testing on Different Screen Sizes
Building Responsive and Adaptive UI in Jetpack Compose is only half the battle. You need to test it too!
Compose makes testing easier with preview annotations:
Pro tip: Add multiple preview annotations to see your layout on different devices simultaneously in Android Studio. This catches issues early and saves you tons of time.
You can also create custom previews for specific dimensions:
1. Hardcoding sizes: Don’t use fixed pixel values like Modifier.width(400.dp) when you can use fillMaxWidth() or weight(). Let Compose do the math.
2. Forgetting about landscape mode: Always test in both portrait and landscape. A layout that works great in portrait might completely break in landscape.
3. Ignoring content density: Large screens don’t just mean “make everything bigger.” Think about optimal content width. A text paragraph shouldn’t stretch across a 12-inch tablet — it becomes hard to read.
4. Not using window size classes: Checking exact pixel dimensions leads to brittle code. Stick with window size classes for more maintainable Responsive and Adaptive UI in Jetpack Compose.
5. Over-engineering: Start simple. You don’t need a different layout for every possible screen size. Compact, medium, and expanded are usually enough.
Advanced Technique: Content-Based Breakpoints
Sometimes you want to switch layouts based on content, not just screen size. Here’s a clever approach:
Kotlin
@ComposablefunContentAwareLayout(items: List<String>) {BoxWithConstraints {// Calculate how many items fit comfortablyval itemWidth = 120.dpval spacing = 16.dpval itemsPerRow = (maxWidth / (itemWidth + spacing)).toInt().coerceAtLeast(1)if (itemsPerRow >= 4) {// Show grid layoutLazyVerticalGrid(columns = GridCells.Fixed(itemsPerRow)) {items(items) { item ->GridItemCard(item) } } } else {// Show list layoutLazyColumn {items(items) { item ->ListItemCard(item) } } } }}
What makes this special..? Instead of using predefined breakpoints, we calculate how many items can fit based on their desired width. If we can fit 4 or more, we use a grid. Otherwise, we use a list. This adapts beautifully to any screen size.
Making Images Responsive
Images need special attention in responsive layouts:
We’re adjusting both the size and aspect ratio of images based on screen size. On phones, full-width images look great. On tablets, fixed-width images with appropriate aspect ratios provide better visual balance.
Performance Considerations
Responsive and Adaptive UI in Jetpack Compose can affect performance if you’re not careful. Here are some tips:
Use derivedStateOf for calculations: If you’re computing values based on window size, use derivedStateOf to avoid unnecessary recompositions.
This complete example shows a real app structure with multiple screens, adaptive navigation, responsive grids, and the master-detail pattern. It’s production-ready code that handles phones, tablets, and everything in between.
Best Practices Recap
Let me summarize the key principles for building great Responsive and Adaptive UI in Jetpack Compose:
Use window size classes as your primary decision-making tool. They’re Google’s recommended approach and they work great.
Think in terms of layouts, not devices. Don’t try to detect if something is a “tablet” or “phone.” Focus on the space available and choose the best layout for that space.
Start with the smallest screen and work your way up. It’s easier to add features for larger screens than to remove them for smaller ones.
Test early and often on different screen sizes. Use previews during development and test on real devices before shipping.
Keep content readable. Don’t let text lines stretch across a 12-inch screen. Consider maximum content width even on large displays.
Use flexible layouts like Row, Column, Box with weights and size modifiers rather than hardcoded dimensions.
Remember that orientation matters. A phone in landscape might have more width than a small tablet in portrait.
What About Foldable Devices?
Foldables add another dimension to responsive design. The window size class approach handles them automatically, but you can also detect fold states if needed:
Kotlin
@ComposablefunFoldableAwareLayout() {val windowSizeClass = calculateWindowSizeClass(activity = LocalContext.current as Activity)// Window size classes automatically adjust when device unfoldswhen (windowSizeClass.widthSizeClass) { WindowWidthSizeClass.Compact -> {// Folded state - show compact layoutCompactLayout() } WindowWidthSizeClass.Medium, WindowWidthSizeClass.Expanded -> {// Unfolded state - show expanded layoutExpandedLayout() } }}
The beauty of this approach is that you don’t need special foldable detection code. The window size class changes automatically when the device folds or unfolds, and your UI adapts accordingly.
Conclusion
Building Responsive and Adaptive UI in Jetpack Compose might seem daunting at first, but once you understand the core concepts, it becomes second nature.
Remember: window size classes are your friends. Use them to make high-level layout decisions. Combine them with BoxWithConstraints when you need fine-grained control. Test on multiple screen sizes. And always think about how your UI will adapt as screens get bigger or smaller.
The great thing about Compose is that it gives you all the tools you need without the complexity of XML layouts and configuration qualifiers. Everything is code, everything is Kotlin, and everything makes sense.
Your users will thank you when your app looks beautiful on their phone, stunning on their tablet, and perfect on their foldable device.
Now go build something awesome..! And remember — responsive design isn’t just about making things fit. It’s about creating the best possible experience for every screen size.
Jetpack Compose introduces a very different mental model compared to XML-based Android UI. One line that often confuses beginners (and even experienced Android devs at first) is:
Kotlin
onValueChange = { value = it }
Especially when value is defined like this:
Kotlin
varvaluebyremember { mutableStateOf(0) }
At first glance, this line looks almost too simple — and that’s exactly why it’s confusing.
Let’s break down what’s really happening, why it’s written this way, and how it fits into Compose’s state-driven architecture.
The Big Picture: Compose Is State-Driven
Before diving into syntax, it’s important to understand how Compose thinks.
In classic Android:
You updated UI elements directly
UI held its own state
You manually synced UI ↔ data
In Jetpack Compose:
State owns the UI
UI is a function of state
When state changes → UI recomposes automatically
This single line:
Kotlin
onValueChange = { value = it }
is the bridge between user interaction and state updates.
What remember { mutableStateOf(...) } Really Does
Consider this state declaration:
Kotlin
varvaluebyremember { mutableStateOf(0) }
This does three important things:
1. mutableStateOf
Creates an observable state holder. Compose watches this value and tracks where it’s used.
2. remember
Ensures the state survives re-composition. Without remember, the value would reset every time Compose redraws the UI.
3. by keyword
This is Kotlin property delegation. It allows you to write:
Kotlin
value = 5
instead of:
Kotlin
value.value = 5
So value behaves like a normal variable, but Compose is quietly observing it.
What onValueChange Is (Conceptually)
Most interactive Compose components (such as TextField, Slider, Checkbox) follow the same pattern: