Skip to content
Go back

Front Wing, Not Small Wing: Design Tokens for Native UI in KMP

by KMP Bits

KMP Bits Cover

Open the FIA technical regulations for a Formula 1 car and look for a part called “small wing”. It isn’t there. There is a front wing, a rear wing, a floor, a diffuser. Every part is named by what it does and where it sits, and every team on the grid reads the same document. The regulation sets the dimensions of the front wing once. How each team shapes the carbon fibre inside those limits is their business.

Nobody ever wrote “wing size A, wing size B, wing size C” and asked the teams to guess which one goes on the nose.

That is what my corner radii looked like.


A while ago I wrote about enforcing design system rules with Detekt, and I was pleased with myself. Colors, spacing, all of it behind a rule. Then I started moving the iOS side of a template project onto the same tokens, and I went looking for every rounded corner in both apps.

I found three tokens on Android: small, medium, large. They were Material’s shape scale, set to 4, 4 and 0. I hadn’t thought hard about them, so the components had quietly stopped using them. The text field used itemPadding as a radius. The snackbar used Space.XS. The alert dialog used screenPadding. The developer app card used Custom(DimensionConstants.TEN). A spacing token doing the job of a shape, because none of the three sizes fit.

Then I put the two platforms side by side.

The card was 4 on Android and 16 on iOS. The bottom sheet was 16 in one component and 24 in another. A button that looked like a pill on Android was 25 on iOS. None of it was a decision. It was drift, and I had a lint rule watching for hardcoded colours the whole time.

The problem never was that the values were wrong. The problem was that medium doesn’t say what it’s for, so every component picks a number and every platform picks a different one.


Name the part, not the size

The fix is one change of vocabulary. A shape token is named for the role it plays:

<!-- core/ui/src/commonMain/design-tokens/shapes.xml -->
<shapes>
    <shape name="field" radius="12.0"/>
    <shape name="button" radius="100.0"/>
    <shape name="card" radius="16.0"/>
    <shape name="dialog" radius="16.0"/>
    <shape name="sheet" radius="24.0" edges="top"/>
    <shape name="snackbar" radius="4.0"/>
    <shape name="thumb" radius="12.0"/>
    <shape name="avatarItem" radius="8.0"/>
    <shape name="skeleton" radius="12.0"/>
</shapes>

Twelve roles in the real file, nine here. Several of them share a value. field, thumb and skeleton are all 12, and that is fine. The number is not the token. The role is. If I decide tomorrow that thumbnails should be tighter than text fields, I change one line and nothing else moves.

The rule I settled on for adding a new one: ask whether a role with the same function already exists. A second card reuses card. A genuinely new function, a tooltip or a badge, gets its own line, even if the radius matches something else. What you must not do is name a token after its size. cardLarge is medium with extra steps.

One attribute is worth a note. edges="top" exists because a bottom sheet only rounds its upper corners, and that has to be expressible in the file rather than special-cased in a component.


One file, two languages

This is a native UI project. The Android app is Compose, the iOS app is SwiftUI, and there is no shared UI layer. So “shared” can’t mean a shared composable. It means a shared source of truth that each platform compiles into its own idiom.

A Gradle task reads the XML and writes both:

// build.gradle.kts (root)
tasks.register("generateAppShapes") {
    val shapesFile = file("core/ui/src/commonMain/design-tokens/shapes.xml")
    val androidOut = file("androidApp/ui/build/generated/shapes/src/androidMain/kotlin")
    val iosOut = file("iosApp/iosApp/Theme/GeneratedShapes.swift")

    inputs.file(shapesFile)
    outputs.dir(androidOut)
    outputs.file(iosOut)

    doLast {
        val entry = "<shape name=\"(\\w+)\" radius=\"([0-9.]+)\"( edges=\"top\")?/>".toRegex()
        val shapes = entry.findAll(shapesFile.readText()).map {
            Triple(it.groupValues[1], it.groupValues[2], it.groupValues[3].isNotEmpty())
        }.toList()

        // ...build the Kotlin text and the Swift text from `shapes`, write both files
    }
}

The generated Kotlin is a class with one property per role:

// GeneratedShapes.kt (generated, not committed)
class AppShapes internal constructor(
    val field: RoundedCornerShape,
    val button: RoundedCornerShape,
    val card: RoundedCornerShape,
    val sheet: RoundedCornerShape,
    // ...
)

internal val DefaultAppShapes = AppShapes(
    field = RoundedCornerShape(12.0.dp),
    button = RoundedCornerShape(100.0.dp),
    card = RoundedCornerShape(16.0.dp),
    sheet = RoundedCornerShape(topStart = 24.0.dp, topEnd = 24.0.dp),
    // ...
)

And the generated Swift is the same list in SwiftUI’s vocabulary:

// GeneratedShapes.swift (generated, not committed)
struct AppShapes {
    let field = RoundedRectangle(cornerRadius: 12.0)
    let button = RoundedRectangle(cornerRadius: 100.0)
    let card = RoundedRectangle(cornerRadius: 16.0)
    let sheet = UnevenRoundedRectangle(
        topLeadingRadius: 24.0, bottomLeadingRadius: 0,
        bottomTrailingRadius: 0, topTrailingRadius: 24.0
    )
    // ...
}

edges="top" becomes topStart/topEnd on one side and UnevenRoundedRectangle on the other. Same intent, each platform’s own type. Adding a role means editing the XML. The two classes grow on the next build, and no hand-written Kotlin or Swift needs to change.

The task is wired in as a dependency of the Kotlin compile and of embedAndSignAppleFrameworkForXcode, so neither platform can build against stale tokens.

There is one hand-written file left on the Android side, and it’s small on purpose. Material components read MaterialTheme.shapes internally, so they still need to be told which role fills which slot:

// Shapes.kt
internal fun AppShapes.toMaterialShapes() = Shapes(
    extraSmall = snackbar,
    small = field,
    medium = card,
    large = sheet,
    extraLarge = dialog
)

What a component looks like

Both platforms expose the tokens the same way. AppTheme.shapes.card on Android, AppTheme.shapes.card on iOS. Here is the card, once in each:

// TemplateCard.kt
Box(
    modifier = modifier
        .background(
            color = AppTheme.colors.surface,
            shape = AppTheme.shapes.card
        )
        .clip(AppTheme.shapes.card)
)
// TemplateCard.swift
VStack(spacing: spaceByPadding) {
    content()
}
.padding(padding)
.background(AppTheme.colors.surface)
.clipShape(AppTheme.shapes.card)

The component names the role. The radius lives in the XML. Neither file contains a number.

Notice what didn’t happen here. I didn’t need Compose Multiplatform, and I didn’t need a shared UI layer. SwiftUI still renders with SwiftUI, Compose with Compose. The only thing that crossed the boundary was a decision.


The three things that bit me

The Gradle script compiler crashed on a local data class. My first version declared data class Shape(val name: String, ...) inside doLast and used it in nested lambdas while building the output strings. The build failed with an Exception while generating code for: followed by a screen of IR. Swapping the data class for a Triple made it compile. I didn’t dig into why. If your generator task dies with that message, check for a local class before you check anything else.

My first test of the lint rule proved nothing. I added androidx.compose.material3.MaterialTheme to the Detekt ForbiddenImport list, planted a violating import to check it fired, and the build passed. I took that as the rule working. It wasn’t. The import was never inserted, because the sed one-liner I used relies on a GNU address form that macOS sed doesn’t understand, and it failed without telling me. A green build after a planted violation only counts if you confirm the violation was actually there. I redid it with a Python insert, and this time the build failed, which was the correct outcome.

Detekt 1.x wants a plain list. I’d written the ForbiddenImport entries in the map form that newer docs show. On 1.23.8 the config is a list of strings:

# detekt.yml
style:
  ForbiddenImport:
    active: true
    imports:
      - 'androidx.compose.material3.MaterialTheme'
      - 'androidx.compose.foundation.shape.RoundedCornerShape'
      - 'androidx.compose.foundation.shape.CutCornerShape'
    excludes:
      - '**/templatekmp/theme/Theme.kt'
      - '**/templatekmp/theme/Shapes.kt'

Making it stick

A token nobody has to use is a suggestion. The generator gives you the vocabulary. Two lint rules make the old way fail the build.

Android, as above: RoundedCornerShape cannot be imported anywhere except the theme package. CircleShape stays free, because a circle is a shape, not a radius.

iOS has no import to ban, because RoundedRectangle is plain SwiftUI. So the rule is a pattern in SwiftLint:

# .swiftlint.yml
custom_rules:
  no_direct_shape:
    name: "Direct Shape Usage"
    regex: "(RoundedRectangle\\(cornerRadius|UnevenRoundedRectangle\\(|\\.cornerRadius\\()"
    message: "Prohibited: use AppTheme.shapes.* instead of building a rounded shape by hand."
    severity: error

Together they push every rounded corner through a role. With both on, the old failure mode is gone: you can’t reach for a number, so you have to ask which role the corner plays.

There is a limit here, these rules enforce that you use a token. They don’t enforce that Android and iOS use the same token for the same screen. If someone puts card on one platform and dialog on the other, both lint rules are satisfied and the apps disagree again. I don’t have a build-time check for that. Today it’s review and the one-view-per-file convention, which at least makes the two files easy to compare.


The line I hold

A design token should be named for the job it does. The moment its name is a size, every component will interpret it differently and every platform will interpret it differently again, and you won’t find out until you put the two screens next to each other.

This works for more than shapes. Colors, dimensions and typography go through the same pipeline in my project: an XML file, a Gradle task, a generated Kotlin class and a generated Swift struct. I used shapes here because that is where the drift was easiest to see, a corner that is 4 on one phone and 16 on the other. The mechanism doesn’t care what the token holds.

Native UI doesn’t mean two design systems. It means one set of decisions, written down once, and two renderers that have to read them.

Same regulation, two cars. Keep it flat out. 🏁


The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Box, Box: Deep Links in Kotlin Multiplatform