A focused, production-quality example of building a custom theme system for Compose Multiplatform that runs unchanged on Android, iOS, and JVM desktop — a complete, standalone replacement for MaterialTheme, not an extension of it, not a wrapper around it, and not dependent on it in any way.
This project is the multiplatform companion to Android Custom Theme, which builds the same theme system targeting Android alone. The architecture is identical: CompositionLocal distribution, @Immutable color interfaces, @Stable singletons for metrics and typography, remember-keyed caching, and MutableState theme switching. All of that lives in commonMain and is shared without modification across all three platforms. The only platform-specific code is dark mode detection — a single expect fun isSystemInDarkMode() with three actual implementations, one per platform.
The demo app is intentionally minimal. It omits ViewModels, Repositories, navigation, and dependency injection not because production apps don't need them, but because they are orthogonal to what is being demonstrated. The theme system has no opinion about how the rest of your application is structured, and integrates cleanly alongside any architecture you choose.
A companion article walks through the design decisions in detail. (link forthcoming)
Most theming tutorials stop at swapping a few color values. This project shows the complete picture across three platforms:
- A
CompositionLocal-based theme that distributes colors, metrics, and typography through the composition tree without passing parameters everywhere — the same code on all platforms - Three-mode theme switching (System / Light / Dark) with a single
MutableState— noViewModel, no state management library required - Reactive system dark-mode detection on all three platforms: event-driven on Android via
isSystemInDarkTheme(), polled viaproduceStateon iOS and JVM desktop - The
expect/actualpattern used at precisely one point of divergence — leaving the vast majority of the codebase incommonMain, shared and unmodified OsTypedetection on JVM desktop, covering macOS, Windows, and Linux, used to invoke the right OS-level query for dark mode state@Immutableand@Stableannotations applied correctly so the Compose compiler can skip unnecessary recompositionsremember(key)used to cache color objects so new instances are not allocated on every recomposition- Singleton
objectdeclarations for stateless theme components (metrics and typography) that have no reason to be instantiated more than once - Color utility extensions (
darken,lighten) with consistent parameter semantics and correct alpha preservation - A three-tier test structure: common tests that compile and run on every platform, Android instrumented tests, and JVM-specific unit tests
commonMain/
theme/
CustomTheme.kt # CompositionLocals, CurrentTheme enum, CustomTheme composable and accessor object
CustomThemeColors.kt # @Immutable color interface + Dark and Light implementations
CustomThemeMetrics.kt # @Stable singleton — spacing and corner radii
CustomTypeStyles.kt # @Stable singleton — text styles
utils/
SystemMode.kt # expect fun isSystemInDarkMode() — declared here, implemented per platform
extensions.kt # Color.darken() / Color.lighten()
App.kt # Demo composable — shared across all platforms
androidMain/
utils/
SystemMode.kt # actual — isSystemInDarkTheme() (reactive via LocalConfiguration)
MainActivity.kt # Entry point; sets up edge-to-edge
iosMain/
utils/
SystemMode.kt # actual — UITraitCollection polling via produceState
MainViewController.kt # Entry point for UIKit
jvmMain/
system/
OsType.kt # Enum — macOS / Windows / Linux detection
utils/
SystemMode.kt # actual — OS process polling via produceState
main.kt # Entry point; opens the desktop window
commonTest/
ComposeAppCommonTest.kt # CurrentTheme.next() cycling, color extension math
androidInstrumentedTest/
ThemeInstrumentedTest.kt # Compose UI test: verifies CustomTheme initialises to System mode
jvmTest/
system/
OsTypeTest.kt # OS detection logic and enum completeness
CustomTheme is a composable wrapper at the top of App() in commonMain. Because App() is the root composable on all three platforms, the theme is applied once and shared everywhere — no platform-specific wiring required.
Anywhere inside that tree, access theme values through the CustomTheme object:
Text(
text = "Hello",
color = CustomTheme.colorScheme.textOne,
style = CustomTheme.typeScheme.textOne,
)No prop-drilling. No ambient singletons. The values flow through CompositionLocal — the standard Compose mechanism for implicit tree-scoped data. This works identically on all three platforms because CompositionLocal is part of the shared Compose runtime in commonMain.
The current theme mode is held in a MutableState<CurrentTheme> exposed via CustomTheme.themeMode. Changing it is one line:
val mode = CustomTheme.themeMode
Button(onClick = { mode.value = mode.value.next() }) { ... }next() cycles through System → Light → Dark → System. No when blocks at the call site — the enum owns its own transition logic. This code is in commonMain and runs identically on all three platforms.
CurrentTheme.System defers to the OS setting at runtime. How that setting is read differs by platform, but the interface is identical: a single @Composable function isSystemInDarkMode() declared as expect in commonMain with three actual implementations.
Android uses Compose's isSystemInDarkTheme(), which reads from LocalConfiguration.current. Because it is a composition local, Compose automatically recomposes affected subtrees when the system theme changes — no broadcast receivers, no lifecycle observers required.
iOS reads UITraitCollection.currentTraitCollection.userInterfaceStyle inside a produceState block. The value is checked every two seconds; when it changes, produceState updates its State, which triggers recomposition in any composable that reads the result.
JVM desktop spawns a short-lived OS process to query the system dark mode setting — defaults read on macOS, a registry query on Windows, gsettings on Linux — also inside produceState with a two-second polling interval. The OsType enum selects the right command at startup using System.getProperty("os.name"), and the blocking process calls are dispatched on Dispatchers.IO to keep the main thread free.
The expect declaration lives in commonMain/utils/SystemMode.kt:
@Composable
expect fun isSystemInDarkMode(): BooleanEach platform provides its actual in its own utils/SystemMode.kt. The compiler enforces that every declared platform target has an implementation. The function is @Composable on both sides, so each actual has access to the full Compose toolkit — produceState, remember, composition locals — without restriction.
This is intentionally the only expect/actual in the project. The goal is to show how little platform-specific code a multiplatform theme actually requires: one function declaration, three implementations, everything else shared.
- Add a property to the
CustomThemeColorsinterface. - Implement it in both
CustomThemeColorsDarkandCustomThemeColorsLight. - Access it anywhere via
CustomTheme.colorScheme.yourProperty.
The compiler enforces that both implementations stay in sync. This process is identical to the Android project.
Add a property to the CustomThemeMetrics or CustomTypeStyles object. Because these are singletons with no light/dark variants, there is nothing else to update.
The when block in CustomTheme.kt is the only place that maps a CurrentTheme value to a color implementation. Adding a high-contrast scheme, for example, means:
- Adding
HighContrastto theCurrentThemeenum - Creating a
CustomThemeColorsHighContrastclass - Adding one branch to the
whenblock
Everything else — the CompositionLocal distribution, the state management, the next() cycling, the platform entry points — adapts automatically. Because this logic is in commonMain, the change applies to all three platforms simultaneously.
Common and JVM tests (no device required):
./gradlew :composeApp:jvmTestThis runs both the commonTest source set (shared logic: CurrentTheme.next() cycling, color extension math) and the jvmTest source set (OsTypeTest). The common tests compile independently for each platform; running them against the JVM is the fastest way to validate shared logic during development.
Android instrumented tests (requires a connected device or emulator):
./gradlew :composeApp:connectedAndroidTestAll non-device tests:
./gradlew :composeApp:allTestsThe common tests cover CurrentTheme.next() cycling and the darken/lighten extension math. One subtlety worth noting: Compose's lerp(Color, Color, Float) converts to a linear light color space before interpolating and converts back afterward, so midpoint channel values do not follow simple sRGB fractions. The tests account for this by asserting direction (darker/lighter than the original) rather than exact channel values, and use a tolerance of 0.002f for boundary assertions to accommodate 8-bit channel quantization (maximum rounding error: 0.5 / 255 ≈ 0.00196).
The JVM-specific OsTypeTest verifies that OsType.current correctly identifies the platform running the tests and that the enum's entries are complete.
| Language | Kotlin 2.3.21 |
| UI framework | Compose Multiplatform 1.10.3 |
| Android Gradle Plugin | 8.13.2 |
| Min SDK (Android) | 24 (Android 7.0) |
| Target SDK (Android) | 36 |
| JVM target | 11 |
This is unrelated to the theme system, but worth noting for developers who are just getting started with Compose Multiplatform on an Intel-based Mac.
Newer KMP project templates sometimes generate an Xcode project that fails to build for the iOS simulator on Intel hardware. Two files need to be patched:
composeApp/build.gradle.kts — add iosX64() to the iOS target list:
listOf(
iosArm64(),
iosSimulatorArm64(),
iosX64() // required for Intel Mac simulators
)iosApp/iosApp.xcodeproj/project.pbxproj — in both the Debug and Release XCBuildConfiguration blocks for the iosApp target, change:
ARCHS = arm64;
to:
ARCHS = (arm64, x86_64);
Both fixes are already applied in this project.
Before Roy Watson wrote a line of Android code, he was programming the propellant management system for NASA's Delta launch vehicles, building radiation detection software for Los Alamos and Sandia National Laboratories, and developing embedded vision systems for IBM's autonomous vehicle research. After 30+ years as a systems developer, he approaches Android differently than most.
Over the past 16 years he has specialized in Android, staying current with every major evolution of the platform: Kotlin, Jetpack Compose, and now Compose Multiplatform (KMP) for iOS, Desktop, and Web. Recent clients include the New York Public Library, Auddia, Bechtel, and Goodyear. When performance work requires dropping into C/C++, SIMD intrinsics, or custom memory management, he does not hand it off.
His personal published apps have accumulated 70,000+ Android downloads (4.5★) and 207,000+ iOS downloads (4.6★). He studied Physics and Mathematics at Purdue University and is a member of Mensa International.
This project is part of a series of public examples demonstrating current Android and Compose Multiplatform architecture and patterns. The series begins with Android Custom Theme, a focused look at building the same theme system for Android alone. The two projects are designed to be read together: the Android project establishes the architecture; this project shows how naturally that architecture extends to multiple platforms, and how little additional code multiplatform actually requires.
Available for contract and full-time engagements — roywatson.app
roywatson.app · linkedin.com/in/roywatson3 · github.com/roywatson