Dynamic colour takes your brand palette away, and that's the deal

Dynamic colour derives a full Material scheme from the user's wallpaper. It improves the feel of most apps and destroys brand recognition if applied indiscriminately, so the interesting work is deciding what stays fixed.

6 min read
androidcomposekotlinmaterial3

Day 45 — Dynamic colour takes your brand palette away, and that's the

Day 45 of 100. Dynamic colour is the most visible thing Material 3 added, and the one that most often gets switched off after a designer sees it.

The symptom

A banking app whose brand colour disappears:

MaterialTheme(
    colorScheme = dynamicLightColorScheme(LocalContext.current),
) { App() }

On a device with an orange wallpaper, the app is orange. The brand blue is gone — not adjusted, gone — including on the logo lockup and the primary call to action.

For a note-taking app that's delightful. For a bank it's a brand problem and, worse, the "confirm payment" button is now the same colour as everything else.

Why the obvious fix fails

The obvious fix is to switch dynamic colour off:

val scheme = if (darkTheme) DarkColorScheme else LightColorScheme    // never dynamic

Defensible, and it throws away something real. Dynamic colour makes an app feel like it belongs on this user's device, and users with accessibility-driven wallpaper choices get a palette that suits them.

The all-or-nothing framing is the actual mistake. The question isn't whether to use dynamic colour; it's which roles are brand and which are chrome.

Dynamic colour replaces the scheme; brand-critical colours are kept outside it

The actual mechanism

dynamicLightColorScheme(context) derives a complete ColorScheme from the wallpaper using Material's colour-extraction algorithm. Every role — primary, secondary, tertiary, all the surfaces and containers — comes from that source, with contrast relationships preserved.

That last part matters: the generated scheme is guaranteed internally consistent. onPrimary will be readable on primary whatever the wallpaper. What isn't guaranteed is that primary is your colour.

So override selectively:

val base = when {
    dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
        if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
    darkTheme -> DarkColorScheme
    else -> LightColorScheme
}

// Brand-critical roles stay fixed; everything else follows the wallpaper
val scheme = base.copy(
    primary = BrandBlue,
    onPrimary = BrandOnBlue,
    primaryContainer = BrandBlueContainer,
    onPrimaryContainer = BrandOnBlueContainer,
)

Surfaces, secondary and tertiary adapt; the primary action stays recognisably yours. That is usually the right trade, and it's a two-line change rather than a decision to abandon the feature.

The one thing to be careful of when overriding: you're now responsible for the contrast you replaced. primary and onPrimary must be checked as a pair, in both modes.

Semantic colours never come from the wallpaper

Some colours carry meaning, and meaning must not depend on a wallpaper:

@Immutable
data class SemanticColors(
    val success: Color,
    val onSuccess: Color,
    val warning: Color,
    val onWarning: Color,
)

val LocalSemanticColors = staticCompositionLocalOf { LightSemanticColors }

ColorScheme has error and no success or warning, which is a genuine gap every app fills. Providing them as a second CompositionLocal alongside MaterialTheme is the standard answer — Day 15's pattern, and staticCompositionLocalOf is right here because the value only changes with the theme.

A green that becomes orange because the wallpaper is orange stops meaning "success". Chart series colours have the same requirement, for the same reason. A five-series chart whose palette shifts between sessions makes the legend the only way to read it.

Contrast levels

Android 14+ lets users request medium or high contrast, and it's an accessibility setting rather than a preference:

val contrast = LocalContext.current.resources.configuration.fontWeightAdjustment
// and for schemes, the dynamic* functions already account for the user's contrast setting

The dynamic schemes handle this for you. A hardcoded scheme does not — which is an argument for dynamic colour that's easy to miss, and a reason to at least test your static palette at high contrast rather than assuming it passes.

Give users the switch

Since the trade is genuinely a matter of taste, the app can defer it:

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = LocalUserPrefs.current.dynamicColor,
    content: @Composable () -> Unit,
) { … }

A settings toggle costs almost nothing and resolves the argument between the designer who wants brand consistency and the user who wants their phone to look coherent. Several first-party Google apps ship exactly this.

Where the toggle lives is worth a moment's thought. It belongs in the app's appearance settings next to the light/dark control, not buried under "advanced" — and it should be labelled in the user's terms ("Use wallpaper colours") rather than as "dynamic colour", which means nothing outside the Material documentation.

How to prove it

Dynamic colour needs a real palette to react to, and the emulator can supply one — change the wallpaper to something saturated, then check the screens where brand matters.

The contrast check is the one to automate, because overriding roles is where it breaks:

@Test fun brandPairMeetsContrast() {
    val ratio = ColorUtils.calculateContrast(
        BrandOnBlue.toArgb(), BrandBlue.toArgb(),
    )
    assertTrue("contrast $ratio", ratio >= 4.5)     // WCAG AA for body text
}

Run it for every pair you override, in both schemes. It's a five-line test that prevents the class of bug where a designer's brand colour is unreadable for some users.

For the visual sweep, preview with three wallpaper-derived schemes and your static one side by side. Anything that changes and shouldn't is a role that needs pinning.

The migration path

Most apps arrive here with a palette rather than a scheme — a Colors.kt of brand hexes and no on* pairings. The conversion is mechanical and worth doing properly once.

Material's theme builder takes a source colour and generates a full scheme, including every container and on* role, in both modes. Feeding it your brand primary produces a starting point that is contrast-correct by construction, which hand-picking a dozen greys never is.

Then the work is at the call sites, and it's the grep from Day 44: each hardcoded colour becomes a role. The mapping questions that come up are usually the same three —

  • A background grey → which surfaceContainer level, judged by hierarchy rather than by matching the old hex.
  • A text grey → onSurfaceVariant, not a lighter onSurface.
  • A divider → outlineVariant.

Matching the previous hex exactly is the wrong goal. The point of the conversion is that the value now follows a role, and roles shift slightly between M2 and M3 palettes by design.

What this generalizes to

The principle is separate identity from chrome. Some colours are load-bearing — brand recognition, semantic meaning, chart encoding — and some are just surfaces. Systems that treat all colour as one bag force an all-or-nothing choice; systems with named roles let you pin the few that matter and let the rest adapt.

That's the payoff for Day 44's role-based scheme. Because primary and surfaceContainer are different names rather than two hex values, "keep the brand, adapt the chrome" is expressible at all — which it wouldn't be if the theme were just a palette.

The same split shows up wherever a system personalises: an editor's syntax theme adapts while its error squiggle stays red, an OS respects your accent colour but not for the battery-low indicator. Meaning is the part that must not move.

Tomorrow, Day 46: the typography scale, and why fifteen named styles is fewer decisions than five.


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