100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Kotlin

Material Design 3 Theming

How Jetpack Compose implements Material 3's color system, typography scale, and dynamic color to give apps a consistent, adaptive visual identity.

Testing & ProductionIntermediate9 min readJul 8, 2026
Analogies

Material Design 3 Theming

Material Design 3 (M3) is Google's design system, and in Jetpack Compose it is implemented almost entirely through composition-local-backed theming rather than XML styles and themes. The MaterialTheme composable wraps your app and provides three coordinated systems to every descendant composable: a ColorScheme (roles like primary, onPrimary, surface, onSurface), a Typography scale (named text styles like titleLarge, bodyMedium), and a Shapes system (rounded-corner definitions for small, medium, and large components). Because these are exposed through CompositionLocal, any composable can read MaterialTheme.colorScheme.primary or MaterialTheme.typography.bodyLarge and automatically pick up whatever theme is active, including live theme switches, without needing to be passed the values explicitly.

🏏

Cricket analogy: MaterialTheme is like a cricket board's official kit guidelines automatically applied to every team's jersey, bat sponsor logo placement, and stadium signage, so any ground crew can pull the correct colors and fonts without being handed the spec sheet directly.

Color Roles, Not Raw Colors

M3's biggest conceptual shift from M2 is designing with semantic color roles instead of a small fixed palette. Each role — primary, secondary, tertiary, error, surface, and their 'on' counterparts (onPrimary, onSurface, etc.) — has a defined contrast relationship with its pairing, so text and icons placed on top of a role-colored background are automatically legible. Composing with roles instead of hardcoded hex values means a single call to lightColorScheme() or darkColorScheme() (or a generated dynamic scheme) restyles the entire app consistently, and it is what makes accessible contrast and dark-theme support largely automatic rather than something each screen must reimplement.

🏏

Cricket analogy: Semantic color roles are like a team always wearing 'home kit' and 'away kit' roles rather than a fixed hardcoded jersey color, so switching stadiums (themes) automatically keeps the team legible against any background, the way onPrimary text stays readable on a primary-colored surface.

Dynamic Color

On Android 12 and above, M3 supports dynamic color, where the ColorScheme is algorithmically derived from the user's wallpaper via dynamicLightColorScheme(context) / dynamicDarkColorScheme(context), so the app's palette visually matches the rest of the user's device. Because dynamic color is not available below API 31, production themes should fall back to a static, brand-defined color scheme on older devices, typically behind a build-version check, so the app still looks intentional rather than defaulting to Compose's plain baseline colors.

🏏

Cricket analogy: Dynamic color deriving the palette from the user's wallpaper is like a stadium's big screen automatically matching its graphics to the home team's kit colors on the day, but on an older scoreboard system that can't do that (pre-API 31), organizers fall back to a fixed default color scheme.

kotlin
@Composable
fun AppTheme(
    useDarkTheme: 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 (useDarkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        useDarkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

@Composable
fun PrimaryActionCard(title: String) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primaryContainer,
            contentColor = MaterialTheme.colorScheme.onPrimaryContainer
        ),
        shape = MaterialTheme.shapes.medium
    ) {
        Text(text = title, style = MaterialTheme.typography.titleMedium)
    }
}

Think of MaterialTheme as three nested dials rather than one: color, typography, and shape are independently overridable. You can nest a second, smaller MaterialTheme around just a sub-tree (e.g. a promotional banner) to apply a different color scheme locally, and everything below it inherits the override while the rest of the app is unaffected.

A frequent mistake is hardcoding Color(0xFF...) values directly in composables instead of referencing MaterialTheme.colorScheme roles. This breaks dark-theme support and dynamic color, and forces manual updates across the codebase whenever the brand palette changes; always theme through roles unless a color is genuinely brand-fixed (e.g. a logo mark).

  • MaterialTheme provides colorScheme, typography, and shapes to the whole composition via CompositionLocal.
  • M3 uses semantic color roles (primary/onPrimary, surface/onSurface, etc.) instead of hardcoded palette colors, guaranteeing contrast pairing.
  • Dynamic color (Android 12+) derives the app's palette from the user's wallpaper via dynamicLightColorScheme/dynamicDarkColorScheme.
  • Provide a static color-scheme fallback for API levels below 31, since dynamic color is unavailable there.
  • MaterialTheme can be nested to scope a different theme to a sub-tree without affecting the rest of the app.
  • Prefer MaterialTheme.colorScheme.* roles over raw hex colors to preserve dark-theme and dynamic-color support.

Practice what you learned

Was this page helpful?

Topics covered

#Kotlin#AndroidWithJetpackComposeStudyNotes#MobileDevelopment#MaterialDesign3Theming#Material#Design#Theming#Color#StudyNotes#SkillVeris