Dynamic Color and Material You Theming in Jetpack Compose
Material You dynamic color lets your Compose app derive its entire palette from the user's wallpaper on Android 12+, and the whole thing hinges on one idea: you stop styling components with hex values and start styling them with semantic color roles. Once your UI references primary, surfaceContainer, and onSurfaceVariant instead of Color(0xFF6200EE), swapping in a wallpaper-derived scheme — or a brand scheme, or a high-contrast scheme — is a single line at the theme root. I've retrofitted this onto an app that hardcoded colors everywhere, and 90% of the work was the migration to roles; the dynamic part was almost free once that was done.
Roles, not colors
The mental shift Material 3 asks for is the important one. In the old world you had a handful of named colors and you'd reach for them ad hoc. In M3, color is a system of roles generated from tonal palettes:
- Primary / secondary / tertiary and their
on*pairs for content drawn on top. - Surface family —
surface,surfaceContainerLowestthroughsurfaceContainerHighest,surfaceVariant— for backgrounds at different elevations. - Utility roles —
outline,outlineVariant,error,scrim.
Each role guarantees a legible pairing (primary with onPrimary), so if you always draw text with the matching on* role you get correct contrast automatically across light, dark, and dynamic schemes. The discipline: never pick a color because it "looks right," pick the role that matches the element's meaning.
Wiring dynamic color into the theme
The actual dynamic color call is small. The pattern is to resolve the scheme once in your app theme and let everything inherit it:
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
darkTheme -> BrandDarkColors
else -> BrandLightColors
}
MaterialTheme(colorScheme = colorScheme, typography = AppType, content = content)
}
That's the entire integration. The version guard is mandatory — dynamicLightColorScheme only exists on API 31+ — and the else branches are your hand-authored brand fallback so pre-Android 12 users still get a coherent, on-brand look.
Build your fallback scheme properly
The fallback is where teams get lazy and it shows. On a pre-12 device your brand ColorScheme is the whole experience, so generate it correctly rather than filling in five colors and leaving the rest at defaults. Use the Material Theme Builder to seed a full tonal palette from your brand color; it produces every role with proper tonal steps. Paste those into lightColorScheme(...)/darkColorScheme(...). A half-filled scheme leaks default purple into surfaceContainerHigh and looks broken on exactly the older devices you're trying to serve well.
Contrast and accessibility
Dynamic color can pair a muted wallpaper hue with a light surface and produce marginal contrast. Two safeguards:
- Trust the
on*roles. Because they're generated to contrast with their base role,onSurfaceonsurfaceis safe. Contrast problems almost always come from mixing roles that weren't designed to pair —primarytext onsurfaceVariant, say. - Support the system contrast setting. Android 14+ exposes user contrast preferences; the dynamic scheme APIs and the material color utilities can produce medium/high-contrast variants. Respect them for users who need it rather than assuming your default tones are enough.
Verify with a real contrast checker at design time, especially for onSurfaceVariant used on secondary text, which is the pairing most likely to sit near the 4.5:1 line.
When not to go fully dynamic
Dynamic color is a personalization feature, not an unconditional good. A brand with strong equity in a specific color loses recognizability if its primary button becomes whatever the wallpaper dictates. My usual compromise:
- Keep key brand moments (the main CTA, logo lockups, splash) on fixed brand color.
- Let ambient surfaces (backgrounds, cards, nav containers) go dynamic so the app still feels woven into the system.
- Offer dynamic color as a toggle for users who want it, defaulting to whatever matches your brand strategy.
This is a product decision, not a technical one, and it's worth a real conversation with design rather than flipping dynamicColor = true and moving on.
Testing that actually catches problems
Dynamic color multiplies your visual surface area: every screen now has many possible palettes. Manually eyeballing one wallpaper proves nothing. Two habits help:
- Cycle wallpapers deliberately — pick a vivid one, a muted one, a near-monochrome one, and a very dark one, in both light and dark mode. Bugs hide in the extremes.
- Screenshot test the theme layer. Rendering key screens against a few fixed seed colors with a tool like Paparazzi catches role-mapping regressions — someone hardcoding a hex, or a component that stopped using
onSurface— before they ship.
Because everything routes through roles, the same design-system discipline that keeps a Compose codebase maintainable is what makes dynamic color trivial to add later. Roles first, dynamic color second.
What I'd take away
Dynamic color isn't really a feature you "add" — it's the reward for theming against semantic roles instead of raw colors. Do the role migration, generate a complete brand fallback scheme with the theme builder, guard the dynamic APIs behind a version check, lean on on* roles for automatic contrast, respect system contrast settings, and treat "how much to go dynamic" as a branding decision. Get the roles right and you can flip between wallpaper-driven, brand, and high-contrast palettes from a single point in your theme.
Resources
- Material 3 dynamic color
- Material 3 color system
- Material Theme Builder
- Compose Material 3 theming guide
- Color contrast accessibility (web.dev)
Frequently asked questions
How do I enable dynamic color in Jetpack Compose?
Call dynamicLightColorScheme(context) or dynamicDarkColorScheme(context) on Android 12+ and pass the result to MaterialTheme, guarding with a build version check. On older versions, fall back to your brand color scheme. Wrap the choice in your app theme composable so every screen inherits the resolved scheme.
What are color roles in Material 3?
Material 3 replaces fixed colors with semantic roles like primary, onPrimary, surface, surfaceContainer, and outline, each generated from tonal palettes. You style components against roles rather than raw hex values, which is what lets dynamic color and light/dark switching work without touching component code.
Should I always use dynamic color?
Not always. Dynamic color strengthens system cohesion and personalization, but it can dilute a strong brand identity and makes some screens unpredictable. Many apps ship dynamic color as an opt-in or use it only on secondary surfaces while keeping brand colors on key components. Test both light and dark and multiple wallpapers before committing.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →