MaterialTheme takes three things: a ColorScheme, a Typography, and a Shapes. Your design system almost certainly has more than three things in it — a spacing scale at minimum, probably elevation semantics, maybe a density knob — so at some point you write a layer that carries the rest.
That layer is about forty lines. Three of the decisions inside it are the kind you discover you got wrong six months later, and one of them I did get wrong, in code that's published on Maven Central right now. This is the walkthrough I wanted when I wrote FormaUI's.
What you're actually filling in
Worth being precise about the gap, because it's smaller than the discourse suggests. MaterialTheme handles colour, type and corners properly — those are genuinely themeable and genuinely inherited. What it has no concept of is spacing (no dimension tokens at all; every component's padding is a private constant in androidx), elevation as a semantic (per-component defaults exist, but there's no theme.elevation.raised to point at), density, and anything domain-specific — chart series colours, a success role, a tabular-numeral text style.
So the layer's job is narrow: carry the tokens M3 doesn't model, and make sure the tokens M3 does model are actually reaching it. Both halves matter, and the second one is where most hand-rolled layers quietly fail.
The shape of it
Three pieces: composition locals to hold the values, a wrapper that provides them, an object to read them back.
internal val LocalFormaSpacing = staticCompositionLocalOf { FormaSpacing() }
internal val LocalFormaShapes = staticCompositionLocalOf { FormaShapes() }
internal val LocalFormaTypography = staticCompositionLocalOf { FormaTypography() }
@Composable
fun FormaTheme(
colorScheme: FormaColorScheme = FormaTheme.defaultColorScheme(),
typography: FormaTypography = FormaTheme.defaultTypography(),
shapes: FormaShapes = FormaTheme.defaultShapes(),
dynamicColor: Boolean = false,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val dynamicScheme = if (dynamicColor) dynamicColorSchemeOrNull(darkTheme) else null
val resolvedColorScheme = dynamicScheme
?: if (darkTheme) colorScheme.dark else colorScheme.light
CompositionLocalProvider(
LocalFormaSpacing provides FormaSpacing(),
LocalFormaShapes provides shapes,
LocalFormaTypography provides typography,
) {
MaterialTheme(
colorScheme = resolvedColorScheme,
shapes = shapes.material,
typography = typography.material,
content = content,
)
}
}
That's the whole wrapper. The interesting parts are staticCompositionLocalOf, shapes.material, and one line that's a bug.
Decision 1: static or dynamic local?
compositionLocalOf tracks reads. Change the provided value and Compose recomposes exactly the composables that read it. staticCompositionLocalOf does not track reads — it's cheaper to read, and the price is that changing the provided value invalidates the entire content lambda beneath the provider.
For theme tokens, static is the right call, and the reason is a question about your app rather than about Compose: how often does this value change? A theme's tokens change essentially never — once at startup, maybe again when the user flips dark mode, which is a full-screen repaint anyway. Paying read-tracking overhead on every FormaTheme.spacing.md in the tree to optimise a transition that already redraws everything is the wrong trade.
It becomes the wrong call the moment a token is genuinely dynamic. If you ship a compact/comfortable density switch that users toggle in a settings sheet, or a per-screen spacing override, a static local means every toggle re-runs your whole app's composition. That's the case for compositionLocalOf, and it's worth knowing which of the two you signed up for before the feature request arrives.
Both of Material's own theme locals are static, for the same reason.
Decision 2: hand your tokens to MaterialTheme as well
This is the half people skip, and it's the one that decides whether the app looks designed or half-migrated.
Your components read FormaTheme.shapes.lg and get 12dp corners. Fine. But your app also contains M3 components you never wrapped — a DropdownMenu, an AlertDialog, a Surface someone reached for directly — and those read MaterialTheme.shapes. If you only provide your own local, those keep M3's stock 12dp/16dp/28dp and your carefully tightened corners stop at the boundary of your own component set.
So the token class exposes a translation:
@Immutable
class FormaShapes(
val none: CornerBasedShape = RoundedCornerShape(0.dp),
val xs: CornerBasedShape = RoundedCornerShape(4.dp),
val sm: CornerBasedShape = RoundedCornerShape(6.dp),
val md: CornerBasedShape = RoundedCornerShape(8.dp),
val lg: CornerBasedShape = RoundedCornerShape(12.dp),
val xl: CornerBasedShape = RoundedCornerShape(16.dp),
val pill: CornerBasedShape = CircleShape,
val full: CornerBasedShape = CircleShape,
) {
val material: Shapes = Shapes(
extraSmall = xs,
small = sm,
medium = md,
large = lg,
extraLarge = xl,
)
}
Eight tiers down to M3's five slots:
| Your tier | Value | M3 slot | M3 stock value |
|---|---|---|---|
| xs | 4dp | extraSmall | 4dp |
| sm | 6dp | small | 8dp |
| md | 8dp | medium | 12dp |
| lg | 12dp | large | 16dp |
| xl | 16dp | extraLarge | 28dp |
| none, pill, full | 0dp / pill / pill | — | no slot |
Two things fall out of that table. The first is that the mapping is lossy in both directions — you have tiers M3 has nowhere to put, and a semantic disagreement in the middle where your md is a control corner and M3's medium is a card corner. Pick which of your tiers is the least-wrong fit for each slot, then write the mapping down where someone can read it, because the next person will assume it's identity.
The second is that a stock DropdownMenu inside this theme comes out at 8dp instead of 12dp. That's not a leak; it's the entire point. Do the same for typography — a Typography handed to MaterialTheme — and every un-wrapped Text in your app inherits your type scale for free.
Decision 3: the accessor object, and @ReadOnlyComposable
The read side mirrors Material's MaterialTheme object convention, so it's already familiar:
object FormaTheme {
val spacing: FormaSpacing
@Composable @ReadOnlyComposable
get() = LocalFormaSpacing.current
val shapes: FormaShapes
@Composable @ReadOnlyComposable
get() = LocalFormaShapes.current
}
@ReadOnlyComposable is the part worth understanding rather than copying. It asserts to the compiler that this composable only reads — emits no nodes, introduces no group of its own — which lets Compose generate a cheaper call with no recompose scope attached. For a getter that returns SomeLocal.current it's free performance and exactly correct.
It's also a promise you can break silently, because putting a remember inside one is a lie about group structure that nothing will flag. The rule I use: if the getter's body is a .current, a constant, or arithmetic over those, annotate it. If it contains remember, LaunchedEffect, or anything that emits, don't.
The same annotation is what makes per-component defaults work as theme reads:
object FormaButtonDefaults {
val shape: Shape
@Composable @ReadOnlyComposable
get() = FormaTheme.shapes.md
}
Which, incidentally, is the fix for M3's button shape not being themeable — ButtonDefaults.shape resolves to a hardcoded CircleShape rather than reading MaterialTheme.shapes. I wrote about that in more detail in the token-by-token comparison; the short version is that a default declared as a theme read is themeable and a default declared as a constant is not, and that distinction is most of what an opinionated layer is for.
What I got wrong: allocating tokens inside the provider
Look at that provider again:
CompositionLocalProvider(
LocalFormaSpacing provides FormaSpacing(), // ← new instance, every time
LocalFormaShapes provides shapes,
LocalFormaTypography provides typography,
) { … }
FormaSpacing() constructs a fresh object on every composition of FormaTheme. FormaSpacing is annotated @Immutable — but @Immutable is a promise to the compiler about mutation, not a generated equals. It's a plain class, so equality is identity, so every one of those fresh instances compares unequal to the last one.
Provide a changed value to a static local and the entire content lambda beneath it is invalidated. Which means: every recomposition of FormaTheme recomposes the whole app underneath it, forever, because of a constructor call in an argument list.
defaultShapes() has the same shape of mistake — fun defaultShapes(): FormaShapes = FormaShapes(), a new instance per call, used as a parameter default. Typography got it right, and the contrast is instructive:
@Composable
internal fun rememberBrandTypography(): FormaTypography {
val family = rememberPublicSansFamily()
return remember(family) { … }
}
That one is remembered, so it's the same instance across recompositions, so providing it is a no-op after the first pass. One of three tokens got the treatment all three needed.
How much does it actually cost? In the common case, close to nothing — and I'd rather say that than oversell my own bug. FormaTheme normally sits at the app root where its arguments are stable and isSystemInDarkTheme() flips maybe twice a day, so it seldom recomposes and the invalidation seldom fires. The case where it hurts is a FormaTheme nested inside a subtree that recomposes often, which is a legitimate thing to do for a themed section of a screen and is exactly where nobody would think to look for the cause.
The fixes are boring, which is the tell that the original code was carelessness rather than a trade-off: hoist the default to a top-level private val (or remember it) so one instance exists for the process, and give the token classes equals/hashCode — a data class, or @Immutable plus a hand-written pair — so even a fresh instance compares equal to the old one and the provider stops churning.
The general lesson, and the reason I'm writing it down: @Immutable and @Stable describe how a type behaves, not how it compares. Compose's skipping logic runs on equals. A token class without one reports "I changed" every time you build a new one, and a composition-local provider is the place where that becomes a whole-subtree cost instead of a single wasted recomposition.
If you're auditing your own layer today, that's the one-line check: are your token classes data classes, and is the instance you provide the same instance you provided last frame?
The limitation I can't design around
FormaTheme takes colorScheme, typography and shapes. It does not take spacing, and LocalFormaSpacing is internal. So FormaUI's 4dp grid — xxs 4, xs 8, sm 12, md 16, lg 24, xl 32, xxl 48, section 96 — is a fixed contract. You can read it. You cannot replace it.
FormaSpacing's own KDoc says "Every value is overridable; construct a custom FormaSpacing to retune the rhythm." You can construct one. There is no way to install it. That sentence is wrong, it's mine, and it's the sort of thing that only gets found when someone tries.
How it happened is ordinary and probably instructive. The hard requirement was that no component hardcode a dp value, which needs the scale to be ambient. Making it configurable was a separate piece of work with no internal customer, so it didn't ship. Ambient-but-fixed is a coherent stopping point that reads, from outside, like an oversight — because it is one.
If your product needs a 5dp or 10dp rhythm, that's a real reason to write your own layer instead of adopting this one, and I'd rather you knew before the dependency than after.
The checklist
If you're writing this layer today:
- Static locals for tokens that never change; dynamic locals for anything a user can toggle. Decide deliberately — the failure mode is invisible until it's a jank report.
-
Provide your tokens to
MaterialThemetoo, or un-wrapped M3 components keep Google's defaults inside your app. - Write the tier→slot mapping down. It's lossy and nobody will guess it.
-
data classyour token classes, and provide a stable instance. It's what stops a static local from invalidating your whole tree. -
@ReadOnlyComposableon every pure token getter, and nowhere else. - Make every token a constructor parameter from day one, even ones nobody's asked to override. Retrofitting one changes a public signature, and by then people are calling it.
Colour, type and corners you get from Material for free if you wire them up. Everything else is yours, and the layer is short enough that the only real risk is being casual with it.
FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. The theme layer described here is dev.formaui:core; every token is documented on the theming page, and you can try the components live in your browser. Every API is @ExperimentalFormaUiApi pre-1.0, including the spacing gap above.
Top comments (0)