Building a Compose Multiplatform Design System as Its Own Module
Building a Compose Multiplatform Design System as Its Own Module
Every Compose screen you have ever written probably began the same way. You wrapped your UI in MaterialTheme { ... } and read values back out through MaterialTheme.colorScheme.primary, MaterialTheme.typography.bodyLarge, and MaterialTheme.shapes.medium, as if the theme were part of Compose itself. It is not. MaterialTheme is one design system, shaped around Material Design, and the day your product needs its own brand palette, its own type ramp, its own corner radii, and its own press feedback, that convenience becomes a ceiling. The deeper question is how you build a design system that owns its tokens, ships its own fonts and drawables, runs on Android, iOS, desktop, and the web, and leans on none of MaterialTheme.
In this article, you'll dive deep into how the open source KotlinConf app builds exactly that, exploring how the design system lives in its own Kotlin Multiplatform module, how it generates a public resource class named
UiRes, how two modules keep their generated resources from colliding, how design tokens flow through CompositionLocal instead of MaterialTheme, how a single theme composable wires colors, shapes, typography, and indication, how ripple is themed through foundation's LocalIndication rather than Material, and how an expect/actual object works around a Compose Resources limitation so dark drawables follow the app's own theme instead of the operating system.
The fundamental problem: A design system welded to MaterialTheme
The natural first move is to lean on MaterialTheme. You build a ColorScheme, a Typography, and a Shapes, then wrap the app root and read tokens back out downstream:
MaterialTheme(
colorScheme = brandColorScheme,
typography = brandTypography,
shapes = brandShapes,
) {
App()
}
This works until your brand stops matching Material's vocabulary. Material's ColorScheme has a fixed set of roles: primary, onPrimary, surface, surfaceVariant, outline, and so on. Your design language has different roles: a tile background, a focused input stroke, a placeholder text color, a past session card. To fit your palette into Material's slots you start overloading role names, mapping strokeInputFocus onto outline and hoping no Material component reads outline and draws something unexpected. The names lie about what the colors mean.
There are two further costs. Depending on MaterialTheme means depending on the whole material or material3 artifact, dragging in components, elevation overlays, and a ripple system you did not ask for. And if the theme lives in the same module as your feature code, every feature that touches the theme inherits Material transitively, and there is no reusable design system boundary to hand to another team. The KotlinConf approach removes all three costs at once: it moves the design system into its own module, and it replaces each piece of MaterialTheme with a foundation based equivalent.
The design system as its own module
The design system is a Gradle module, :app:ui-components, declared in settings.gradle.kts and referenced type safe as projects.app.uiComponents. Its build file starts by applying the Compose Multiplatform plugins alongside the newer Android Kotlin Multiplatform plugin:
plugins {
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinMultiplatform)
}
This declares a standalone Kotlin Multiplatform plus Compose Multiplatform library. The androidMultiplatformLibrary plugin enables the modern androidLibrary { } DSL for the Android target, rather than the older Android Gradle plugin wiring.
Next comes the target list. The module builds for Android, the JVM (desktop), two iOS targets, and two web targets:
kotlin {
applyDefaultHierarchyTemplate()
androidLibrary {
namespace = "com.jetbrains.kotlinconf.ui"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions.jvmTarget = JvmTarget.JVM_11
androidResources.enable = true
}
jvm()
iosArm64()
iosSimulatorArm64()
wasmJs { browser() }
js { browser() }
The iOS targets are iosArm64 for devices and iosSimulatorArm64 for the Apple silicon simulator, with no iosX64. On the Android target, androidResources.enable = true is required so the Compose Resources pipeline can generate and package resources for that target.
The dependency block is where the module draws its boundary. It depends on runtime, foundation, and animation, but not on material or material3:
sourceSets {
commonMain.dependencies {
api(libs.compose.runtime)
api(libs.compose.foundation)
api(libs.compose.animation)
implementation(libs.compose.material.ripple)
api(libs.compose.components.resources)
implementation(libs.compose.ui.tooling.preview)
// ... coil, lifecycle, markdown renderer
}
Notice what is present and what is absent. The runtime, foundation, and animation artifacts are exposed as api so consumers get them transitively. There is no Material theming dependency at all. The single Material artifact is org.jetbrains.compose.material:material-ripple, and it is declared as implementation, so it does not leak to consumers. This is the module avoiding Material theming while still reusing one standalone Material submodule for ripple drawing, a distinction the ripple section returns to.
The last part of the Kotlin block builds an intermediate source set by hand and turns on one compiler flag:
val nonAndroidMain by creating { dependsOn(commonMain.get()) }
configure(listOf(iosMain, jvmMain, webMain)) { get().dependsOn(nonAndroidMain) }
}
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
The nonAndroidMain source set depends on commonMain, and iosMain, jvmMain, and webMain all depend on nonAndroidMain. This is where the shared non-Android implementation of the theme hook lives, so JVM, iOS, and web reuse one file while Android gets its own. The -Xexpect-actual-classes flag is required because the theme hook is an expect/actual object, and expect/actual classes and objects are still experimental in Kotlin.
A resource class of its own: UiRes
A Compose Multiplatform module that carries fonts, drawables, and strings gets a generated accessor class for them. By default that class is an internal object Res. The design system overrides all three of the relevant settings:
compose.resources {
publicResClass = true
nameOfResClass = "UiRes"
packageOfResClass = "org.jetbrains.kotlinconf.ui.generated.resources"
}
Each line changes one thing. publicResClass = true makes the generated accessor public so other modules can read it. nameOfResClass = "UiRes" renames it away from the default Res. packageOfResClass places it in a dedicated package. The Compose Resources Gradle task therefore emits public object UiRes, whose runtime paths are namespaced under composeResources/org.jetbrains.kotlinconf.ui.generated.resources/. Every font, drawable, and string in the module is addressed as UiRes.font.*, UiRes.drawable.*, and UiRes.string.*, and because the class is public those references cross the module boundary.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor