Extending MaterialTheme beats replacing it, until it doesn't

Material models colour, type and shape. Everything else a design system needs — spacing, motion, semantic colour — has to be added. Extending with a parallel CompositionLocal is usually right; a full custom system rarely is.

6 min read
androidcomposekotlinmaterial3

Day 48 — Extending MaterialTheme beats replacing it, until it doesn't

Day 48 of 100. Every real design system needs values Material doesn't model, and the question is whether to extend it or leave it.

The symptom

Spacing, decided independently in every file:

Column(Modifier.padding(16.dp)) {
    Text(title)
    Spacer(Modifier.height(12.dp))
    Text(body)
    Spacer(Modifier.height(24.dp))
    Button(onClick = {}) { Text("Go") }
}

Sixteen, twelve, twenty-four. The next screen uses fourteen, eight and twenty. There's no MaterialTheme.spacing, so everyone types a number, and the app has thirty spacing values where the design has six.

Same story for semantic colours — Day 45's missing success and warning — and for elevation, motion durations and any brand-specific token.

Why the obvious fix fails

The obvious fix is an object of constants:

object Spacing {
    val xs = 4.dp
    val sm = 8.dp
    val md = 16.dp
    val lg = 24.dp
}

This is a genuine improvement and it has one real limitation: it's global and static. It can't vary by theme, so a compact density mode, a tablet-specific scale or a white-label brand with tighter spacing all need a different mechanism — and by then the constants are referenced in three hundred places.

It also sits outside MaterialTheme, so nothing signals that it's part of the theme rather than a utility file.

A parallel CompositionLocal alongside MaterialTheme, read through one AppTheme object

The actual mechanism

Provide your own CompositionLocal alongside Material's, and expose both through one object. That's Day 15's pattern, used for exactly the case it's designed for:

@Immutable
data class Spacing(
    val xs: Dp = 4.dp,
    val sm: Dp = 8.dp,
    val md: Dp = 16.dp,
    val lg: Dp = 24.dp,
    val xl: Dp = 32.dp,
)

val LocalSpacing = staticCompositionLocalOf { Spacing() }

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    spacing: Spacing = Spacing(),
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        LocalSpacing provides spacing,
        LocalSemanticColors provides if (darkTheme) DarkSemantic else LightSemantic,
    ) {
        MaterialTheme(
            colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
            typography = AppTypography,
            content = content,
        )
    }
}

// One accessor object, so call sites read consistently
object AppTheme {
    val spacing: Spacing
        @Composable @ReadOnlyComposable get() = LocalSpacing.current
    val semantic: SemanticColors
        @Composable @ReadOnlyComposable get() = LocalSemanticColors.current
}

Call sites then read the same way for both:

Column(Modifier.padding(AppTheme.spacing.md)) {
    Text(title)
    Spacer(Modifier.height(AppTheme.spacing.sm))
    StatusBadge(color = AppTheme.semantic.success)
}

Three details in that code are load-bearing:

@Immutable tells the compiler the data class never changes, so reading it doesn't make consumers unskippable — Day 7's stability argument.

staticCompositionLocalOf because spacing changes only when the theme does. Day 15: static for values that essentially never change, since a change recomposes the whole subtree.

@ReadOnlyComposable on the accessor skips generating a restartable group for a function that only reads. Small, free, and correct for every accessor of this shape.

The naming decision

AppTheme.spacing versus MaterialTheme.spacing — the second is possible via an extension property, and it's worth not doing:

val MaterialTheme.spacing: Spacing
    @Composable get() = LocalSpacing.current      // don't

It reads well and it lies: MaterialTheme is a Material type and spacing isn't part of Material. When someone reads the Material documentation looking for it, or upgrades and finds a real spacing property, the extension becomes a conflict. Keeping your additions under your own name makes the boundary visible.

When to actually replace Material

A full custom design system — your own ColorScheme equivalent, your own components — is occasionally right, and much less often than teams assume. It's warranted when:

  • Your brand's visual language is genuinely non-Material — a game UI, a heavily illustrated product, a system with a distinctive interaction model.
  • You ship on Compose Multiplatform targeting iOS and desktop, where Material's Android idioms are wrong for the platform.
  • You're building a component library that shouldn't force Material on consumers.

It is not warranted because the buttons look too rounded or the colours are wrong. Both are theme values, and changing them is Days 44–47. The question to ask is whether you disagree with Material's values or with its concepts — the first is theming, the second is a rewrite.

The cost of replacing is easy to underestimate: Material components carry accessibility semantics, touch-target minimums, RTL handling, state layers, ripples and dozens of edge-case behaviours. A hand-rolled Button starts at zero on all of it, and the missing pieces surface one accessibility audit at a time.

The middle path most teams end at: keep Material, theme it heavily, and wrap the components you use in your own API so the dependency is contained.

@Composable
fun AppButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit,
) {
    Button(onClick = onClick, modifier = modifier, shape = AppShapes.button, content = content)
}

Screens depend on AppButton. If Material 4 changes something, or you replace the implementation entirely, the change is in one file.

How to prove it

The extension mechanism is verifiable by preview — provide a different value and check that everything moves:

@Preview
@Composable fun CompactSpacing() = AppTheme(spacing = Spacing(md = 8.dp, lg = 12.dp)) {
    SettingsScreen(sample)
}

If the layout doesn't tighten, something is reading a hardcoded dp. That preview is a better audit than a grep, because it also catches spacing that arrived through a component default rather than through a literal.

For the stability claim, use the compiler report from Day 7 — build with reportsDestination set and read the generated *-classes.txt. Spacing should appear as stable. If it doesn't, either the @Immutable annotation is missing or one of the properties isn't a stable type.

What this generalizes to

The principle is extend at the seam the framework provides. CompositionLocal is the seam; Material uses it for three things and leaves it open for yours. Working with that mechanism gets you theming, previewing and scoped overrides for free.

The alternative — a parallel static object, or a fork of the library — puts you outside the framework's guarantees for values that are conceptually part of the theme. That's usually the more expensive kind of independence, and it's worth being sure you need it before taking it.

What belongs in the extension

A short list covers most of what teams add, and it's worth knowing the shape before inventing your own:

Spacing — the one every codebase needs, as above.

Semantic colourssuccess, warning, and any domain colour (profit/loss, online/offline). Day 45's argument: these carry meaning and must not track a wallpaper.

Elevation tokens — named levels rather than dp literals, so "card elevation" is one decision.

Motion durationsshort, medium, long. Animation is Days 61–66, and durations belong here for the same reason spacing does.

Component sizes — avatar diameters, icon sizes, minimum row heights.

What does not belong: anything derivable, and anything that varies per screen. A headerHeight that only one screen uses is a constant in that screen's file, not a theme token — the theme is for decisions made once and referenced widely, and adding single-use values to it makes the token set hard to trust.

Tomorrow, Day 49 closes the theming pillar with the newer styles API and where Compose's styling story is heading.


Day 48 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Custom design systems.