MaterialTheme is three CompositionLocals and nothing else

MaterialTheme provides three CompositionLocals and a handful of defaults. Understanding its actual shape explains how components pick up styling, why hardcoded values break dark mode, and what a custom theme has to replace.

6 min read
androidcomposekotlinmaterial3

Day 44 — MaterialTheme is three CompositionLocals and nothing else

Day 44 of 100, opening the theming pillar. MaterialTheme wraps every Compose app and most people never look inside it.

The symptom

A card that's invisible in dark mode:

Card(
    colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5)),
) {
    Text("Balance", color = Color.Black)
}

Light mode: a pale grey card with black text, exactly as designed. Dark mode: the same pale grey card, now glaring against a dark background, with black text that's fine but on a surface that isn't.

Every hardcoded colour in the codebase has this bug, and the count is usually in the hundreds by the time anyone checks.

Why the obvious fix fails

The obvious fix is to branch on the theme:

val bg = if (isSystemInDarkTheme()) Color(0xFF2C2C2C) else Color(0xFFF5F5F5)

This works, at every call site, forever. Two hundred components, two hundred branches, each one a place where someone picks a slightly different grey — and none of them respond when the design team changes the palette or when dynamic colour is enabled.

You've solved dark mode by hand-implementing the thing the theme already does.

MaterialTheme provides three CompositionLocals; components read them by role name

The actual mechanism

MaterialTheme is a composable that provides three CompositionLocals — Day 15's mechanism, used for exactly the case it's right for:

@Composable
fun MaterialTheme(
    colorScheme: ColorScheme = MaterialTheme.colorScheme,
    shapes: Shapes = MaterialTheme.shapes,
    typography: Typography = MaterialTheme.typography,
    content: @Composable () -> Unit,
)

That's the whole object. Three values, provided to a subtree, read by components via MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes.

The card becomes:

Card {                                    // uses colorScheme.surfaceVariant by default
    Text("Balance")                       // uses typography.bodyLarge and onSurfaceVariant
}

No colours at all. Dark mode works because the ColorScheme provided at the top is a different object, and every component below reads through it.

This is why the three checks all pass at once: change the scheme, and two hundred components follow.

Roles, not colours

The part that takes longest to internalise: ColorScheme doesn't hold "blue" and "grey". It holds roles — what a colour is for:

MaterialTheme.colorScheme.primary            // the main brand action colour
MaterialTheme.colorScheme.onPrimary          // content ON primary — guaranteed contrast
MaterialTheme.colorScheme.primaryContainer   // a lower-emphasis primary surface
MaterialTheme.colorScheme.onPrimaryContainer
MaterialTheme.colorScheme.surface            // the background of cards, sheets, menus
MaterialTheme.colorScheme.onSurface
MaterialTheme.colorScheme.error / onError

The on* pairing is the load-bearing part. Every background role has a matching content role, and the pair is contrast-checked. Using onPrimary on a primary background is guaranteed readable in light mode, dark mode and any dynamic palette; picking Color.White yourself is guaranteed only today.

The practical rule: never set a text colour and a background colour independently. Pick the background role, and let the content colour come from the pairing — which is what LocalContentColor does automatically inside Material containers.

Surface and elevation

Material 3 changed how elevation reads, and it catches people migrating from M2. In M3, raising a surface's elevation tints it toward surfaceTint rather than only casting a shadow:

Surface(tonalElevation = 3.dp) { … }      // tinted, for hierarchy
Surface(shadowElevation = 3.dp) { … }     // shadow, for lift

The five surfaceContainer roles — surfaceContainerLowest through surfaceContainerHighest — are the current way to express hierarchy, and they're preferable to tonal elevation because they're explicit about which level you meant.

A practical consequence: a Card inside a Card needs different container roles to be visible at all. In M2 the nested one got a shadow; in M3 you choose surfaceContainerLow outside and surfaceContainerHigh inside, and the hierarchy is in the role names rather than in a dp value.

Where the theme goes

Once, at the top:

setContent {
    AppTheme {                      // your wrapper around MaterialTheme
        AppNavHost()
    }
}

Not per screen, and not per component. Nesting a second MaterialTheme deeper down is legitimate for a genuinely different region — a promotional area with its own palette, say — and it's a decision to make deliberately, since everything below it changes:

MaterialTheme(colorScheme = promoScheme) {
    PromoBanner()          // overrides for this subtree only
}

That's Day 15's "would a caller ever want to override this for a subtree" test, and theming is the case where the answer is genuinely yes.

The wrapper worth writing

AppTheme is where your app's decisions live, and it's about fifteen lines:

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val scheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val ctx = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
}

Every screen uses AppTheme, so dark mode, dynamic colour and the brand palette are one decision in one place rather than a policy everyone has to remember.

How to prove it

The hardcoded-colour audit is a grep, and it's worth running once on any existing codebase:

grep -rn "Color(0x\|Color\.White\|Color\.Black" --include="*.kt" app/src/main | grep -v "ui/theme/"

Everything outside the theme package is a candidate bug. A handful are legitimate — a brand logo's exact colour, a scrim — and those deserve a named constant in the theme rather than a literal at the call site.

For the visual check, the dark-mode preview is one annotation:

@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable fun CardPreview() = AppTheme { BalanceCard(sample) }

Two renders side by side. Anything that doesn't change between them is hardcoded.

Reading the theme in your own components

Components you write should read the theme the same way Material's do:

@Composable
fun BalanceCard(balance: Money, modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        shape = MaterialTheme.shapes.medium,
        color = MaterialTheme.colorScheme.surfaceContainer,
    ) {
        Column(Modifier.padding(16.dp)) {
            Text("Balance", style = MaterialTheme.typography.labelMedium)
            Text(balance.format(), style = MaterialTheme.typography.headlineMedium)
        }
    }
}

No colour on either Text. Surface provides LocalContentColor derived from its color, so the text picks up the right contrast automatically — and keeps picking it up if the surface role changes later.

That's the pattern worth internalising: set the container's role, and let content colour follow. The moment a component sets both, the two can disagree, and disagreeing is exactly what a hardcoded pair does in the mode you didn't test.

What this generalizes to

The idea is indirection through named roles. A component that says "I am a surface" survives a palette change; one that says 0xFFF5F5F5 does not. The name expresses intent; the value expresses one answer to that intent, on one day, in one mode.

CSS custom properties, design tokens and semantic colour naming are all the same move, and they all exist because the alternative — literal values at the point of use — makes every global change a find-and-replace with no way to tell a deliberate exception from a missed one.

It also explains why MaterialTheme being only three locals is good news rather than a limitation. A theme that also carried spacing, animation durations, elevation constants and component defaults would be a framework you either accept whole or fight. Three values is small enough to extend — which is Day 48.

Tomorrow, Day 45: colour schemes in detail, and dynamic colour — where the palette comes from the user's wallpaper and your brand has to survive it.


Day 44 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Material Design 3 in Compose.