🎨 Jetpack Compose

Material 3 Theming and Dynamic Colors

A consistent theme makes an app feel professional, supports dark mode, and with Material 3's dynamic color can adapt to each user's wallpaper. Here's how to set up a robust Material 3 theme in Compose, extend it with your own tokens, and avoid hard-coded colours.

✍️ Yunok Tech 📅 Updated ⏱ 3 min read 🎯 Intermediate
On this page
  1. The three pillars of a Material 3 theme
  2. Building the theme
  3. Dynamic color: when to use it
  4. Typography and shapes
  5. Using theme values in components
  6. Extending the theme with your own tokens
  7. Real-world setups
  8. Common mistakes
  9. Frequently asked questions
  10. Conclusion

Hard-coded Color(0xFF6200EE) scattered across screens looks fine until you add dark mode, rebrand, or test contrast. Then every screen needs edits. A well-built Material 3 theme fixes this once: components read roles like primary and surface, and the theme decides the actual colours.

The three pillars of a Material 3 theme

MaterialTheme provides color scheme, typography and shapes MaterialTheme wraps the app and provides three systems: a ColorScheme of roles such as primary, onPrimary, surface and error; Typography with roles such as headline, title, body and label; and Shapes from extra small to extra large. Components read these roles instead of hard-coded values. MaterialTheme { app content } ColorSchemeprimary · onPrimarysurface · surfaceContainererror · outline … Typographydisplay · headline · titlebody · label(Large / Medium / Small) ShapesextraSmall … extraLargecorner radii forcards, sheets, buttons
Components ask for roles, not values. Change the theme and every screen follows.

Building the theme

Generate a palette from your brand colour with the Material Theme Builder, which exports Compose code for light and dark schemes. Then wire it up:

ui/theme/Theme.ktKotlin
private val LightColors = lightColorScheme(
    primary = Color(0xFF0061A4),
    onPrimary = Color(0xFFFFFFFF),
    primaryContainer = Color(0xFFD1E4FF),
    onPrimaryContainer = Color(0xFF001D36),
    // ... remaining roles exported from Material Theme Builder
)

private val DarkColors = darkColorScheme(
    primary = Color(0xFF9ECAFF),
    onPrimary = Color(0xFF003258),
    primaryContainer = Color(0xFF00497D),
    onPrimaryContainer = Color(0xFFD1E4FF),
    // ...
)

@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 -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content)
}

Dynamic color: when to use it

On Android 12 (API 31) and later, dynamic color derives a scheme from the user's wallpaper, making your app feel native to their device. It's not always right:

  • Use it for utility and personal apps where blending with the system is a plus.
  • Make it optional or skip it where brand recognition matters, such as banking, retail or media brands. You can still apply dynamic color only to neutral surfaces.
  • Always provide a fallback scheme for older devices.

Typography and shapes

ui/theme/Type.ktKotlin
val Brand = FontFamily(Font(R.font.inter_regular), Font(R.font.inter_semibold, FontWeight.SemiBold))

val AppTypography = Typography().run {
    copy(
        headlineSmall = headlineSmall.copy(fontFamily = Brand, fontWeight = FontWeight.SemiBold),
        titleMedium = titleMedium.copy(fontFamily = Brand, fontWeight = FontWeight.SemiBold),
        bodyLarge = bodyLarge.copy(fontFamily = Brand),
        labelLarge = labelLarge.copy(fontFamily = Brand, fontWeight = FontWeight.SemiBold),
    )
}

val AppShapes = Shapes(
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(20.dp),
)

Using theme values in components

Read roles, never hard-codeKotlin
Card(
    colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
    shape = MaterialTheme.shapes.large,
) {
    Text("Order #1042", style = MaterialTheme.typography.titleMedium,
        color = MaterialTheme.colorScheme.onSurface)
    Text("Out for delivery", style = MaterialTheme.typography.bodyMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant)
}

Always pair a container with its "on" role (primary with onPrimary, surface with onSurface). Material 3's tonal palettes are designed so these pairs meet contrast guidelines.

Extending the theme with your own tokens

Semantic colors via CompositionLocalKotlin
@Immutable
data class ExtendedColors(val success: Color, val onSuccess: Color, val warning: Color)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(success = Color.Unspecified, onSuccess = Color.Unspecified, warning = Color.Unspecified)
}

// In AppTheme:
// CompositionLocalProvider(LocalExtendedColors provides if (darkTheme) DarkExtended else LightExtended) {
//     MaterialTheme(colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content)
// }

object AppTheme {
    val extendedColors: ExtendedColors
        @Composable get() = LocalExtendedColors.current
}

// Usage: Text("Paid", color = AppTheme.extendedColors.success)

Real-world setups

Brand-first app

Fixed brand schemes for light and dark, with dynamic color disabled. Semantic success and warning tokens via CompositionLocal.

Personal utility app

Dynamic color on by default, with a settings toggle and a brand fallback.

Multi-brand white-label

Build one theme function per brand from the same token structure. Screens never change.

Common mistakes

  • Hard-coding colours and text sizes in components.
  • Using Color.White text on primary, which breaks in dark mode or with dynamic color. Use onPrimary.
  • Forgetting to theme the system bars and the window background, which causes flashes on startup.
  • Not testing large font scale and contrast. Run Android Studio's accessibility checks and try TalkBack.

Frequently asked questions

Should I still define an XML theme?

Yes, a minimal one for the Activity window (background, splash and system bar defaults), even if all UI is in Compose.

Can users choose light or dark in the app?

Yes. Store the preference in DataStore and pass darkTheme into your theme instead of relying only on isSystemInDarkTheme().

Is Material 3 required?

No. You can build a fully custom design system on Compose foundation, but Material 3 gives you accessible, well-tested defaults.

Conclusion

A solid Material 3 theme defines color roles, typography and shapes once, supports dark mode and optional dynamic color, and extends cleanly with your own semantic tokens. Components that read roles instead of raw values make rebrands and accessibility fixes painless.

Next step: keep that polished UI fast with Compose Performance Optimization.

Official references

📱 Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps →

Ready to build real Android applications?

Jetpack Compose clicks once you build real screens with real state. Practise modern Android UI development on projects that go beyond toy examples.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp