Skip to content
All posts

Material You dynamic color in Jetpack Compose: keeping your brand color when the wallpaper wins

dynamicColorScheme() replaces your palette with one built from the user's wallpaper. A practical guide to harmonizing brand colors instead of losing them.

MFKAPPS 5 min read

Turn on dynamic color in a Compose app and the first thing you notice is that your brand disappears. Granyn’s green, Hydrame’s blue — gone, replaced by whatever dynamicColorScheme() derived from the user’s wallpaper. On a stock blue wallpaper it’s fine. On a orange or magenta one, an app whose entire visual identity is “the green one” or “the blue one” now looks like every other app on the phone. That’s Material You working as designed, and it’s also a real problem if color is how a user recognizes your app in a crowded app drawer.

The fix isn’t turning dynamic color off — users who’ve opted into a system-wide theme notice when one app refuses to match. The fix is harmonizing: keeping the wallpaper-derived neutrals and interaction colors, but nudging your brand hue back into the scheme instead of letting it get silently overwritten.

What dynamicColorScheme actually gives you

On API 31+, dynamicLightColorScheme(context) and dynamicDarkColorScheme(context) read android.R.color.system_accent1 through system_accent3 — tonal palettes the system extracted from the wallpaper — and hand back a full Material 3 ColorScheme. It’s convenient because you get a scheme that’s guaranteed to look coherent against the rest of the OS. It’s also a scheme with zero knowledge of what color your app is supposed to be.

@Composable
fun AppTheme(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val colorScheme = when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        else -> if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
    }
    MaterialTheme(colorScheme = colorScheme, content = content)
}

That’s the version most tutorials stop at, and it’s exactly the code that made Hydrame’s water-drop icon and its in-app accent color stop matching on half the phones I tested it on.

Harmonizing instead of replacing

androidx.core.graphics.ColorUtils — no extra dependency, it ships in core-ktx — has a blendHSL you can use to shift a fixed brand color toward the scheme’s dynamic primary, in small steps, until it’s close enough to feel native without losing its identity. Material’s own design guidance calls this “harmonization”: take a color the system doesn’t own (an error red, a brand hue, a data-viz series) and blend it partway toward the nearest dynamic role so it doesn’t clash.

fun harmonize(designColor: Color, dynamicColor: Color, fraction: Float = 0.2f): Color {
    val blended = ColorUtils.blendHSL(
        designColor.toArgb(),
        dynamicColor.toArgb(),
        fraction,
    )
    return Color(blended)
}

Applied to Hydrame, whose brand primary is #3B82F6:

val scheme = dynamicColorScheme(context)
val harmonizedAccent = harmonize(
    designColor = Color(0xFF3B82F6),
    dynamicColor = scheme.primary,
    fraction = 0.15f,
)

At fraction = 0.15f the accent still reads unmistakably as Hydrame blue, but it sits comfortably next to whatever tonal neutral the wallpaper produced instead of fighting it. I use the harmonized color only for the pieces that carry brand identity — the app icon’s in-app echo, the water-drop progress ring, onboarding illustrations — and leave everything else (surfaces, text, dividers, buttons) on the system’s own dynamic roles. Harmonizing every color in the scheme defeats the point of dynamic color in the first place; the goal is one identifiable accent surviving inside an otherwise system-native palette, not a full brand takeover.

Falling back below API 31

Two-thirds of the fallback logic above is one when branch, but it’s worth being deliberate about what the pre-Android-12 scheme looks like, because it’s not a degraded dynamic scheme — it’s your only scheme on those devices, full stop. I keep a hand-built ColorScheme per app using lightColorScheme(primary = ..., secondary = ..., ...) seeded from the exact brand values already in each app’s design tokens (Granyn’s #22C55E, Mintly’s #F59E0B, Subly’s #6366F1), rather than trying to approximate what a dynamic scheme would have produced. On these devices the brand color problem this post is about doesn’t exist — you own the whole palette — so don’t spend effort simulating dynamic behavior you can’t actually get.

Testing across wallpapers, not just light and dark

Dynamic color adds a testing dimension that’s easy to skip: light theme, dark theme, and now a range of wallpaper hues. A screen that looks correct against the default Pixel wallpaper can have unreadable contrast against a saturated one, because system_accent1 shifts hue and chroma together. Before shipping a themed screen I check it against at minimum a cool wallpaper, a warm one, and a low-saturation grayscale one — set from Settings → Wallpaper & style → pick color, which lets you force a specific hue without hunting for a matching image. If your harmonized accent’s contrast ratio against scheme.surface drops below 4.5:1 on any of those, the fixed fraction from above is too aggressive for that hue range and needs a contrast check, not a fixed constant:

fun harmonizeWithContrast(designColor: Color, scheme: ColorScheme, fraction: Float = 0.15f): Color {
    val candidate = harmonize(designColor, scheme.primary, fraction)
    return if (ColorUtils.calculateContrast(candidate.toArgb(), scheme.surface.toArgb()) >= 4.5) {
        candidate
    } else {
        designColor // fall back to the untouched brand color rather than ship low contrast
    }
}

What to check before shipping

  • Every screen that carries brand color (icon echoes, illustrations, charts) uses a harmonized value, not the raw dynamic primary and not the untouched brand hex.
  • The pre-API-31 fallback scheme is a real, hand-tuned ColorScheme, not a copy of the dynamic one with hardcoded values.
  • Contrast is checked against at least one saturated and one low-chroma wallpaper, not just the default.
  • Non-brand surfaces (backgrounds, dividers, default buttons) stay on the system’s dynamic roles untouched — resist the urge to harmonize everything.

Dynamic color is one of the few Android platform features that makes an app feel like it belongs on a specific phone instead of a generic one. It’s worth keeping. It’s just not worth your brand color to keep it.