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.

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.

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 colours — success, 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 durations — short, 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.