Back to Notes

Material 3 Theming in Jetpack Compose

By Davide Agostini

Material 3 Theming in Jetpack Compose

Material 3 (also called "Material You") is Google's latest design system. It brings dynamic colors, better dark theme support, and easier customization to Jetpack Compose.

Let's learn how to use it properly!

Why Material 3?

What Makes M3 Different?

Material 3 (also called "Material You") is a big upgrade from Material 2. Here's what changed:

🎨 Dynamic Colors (NEW!)

  • Material 2: Fixed colors that never change
  • Material 3: Pulls colors from the user's wallpaper (Android 12+)

🌈 Extended Color Tokens

  • Material 2: Just primary, secondary - simple
  • Material 3: Many options - primary, onPrimary, primaryContainer, onPrimaryContainer, etc.

🌙 Better Dark Theme

  • Material 2: Just flips colors to dark (looks flat)
  • Material 3: Creates nuanced dark surfaces with better contrast

✨ Custom Themes

  • Material 2: You had to manually define every color
  • Material 3: Give it ONE color (seed), it generates the whole scheme!

Setting Up Material 3

1. Add Dependency

// build.gradle.kts (app level)
dependencies {
    // ============================================================
    // Material 3 - The main library
    // This includes all components and theming support
    // ============================================================
    implementation("androidx.compose.material3:material3")
    
    // ============================================================
    // Material Icons Extended - Optional but recommended
    // Adds more icons beyond the default set
    // ============================================================
    implementation("androidx.compose.material:material-icons-extended")
}

The Wrong Way vs The Right Way

❌ BAD: Using Hardcoded Colors

// ============================================================
// WHY THIS IS BAD:
// 1. Doesn't work in dark mode
// 2. Hard to maintain
// 3. Doesn't follow Material Design guidelines
// 4. Accessibility issues (contrast)
// ============================================================
 
@Composable
fun BadButton() {
    Button(
        onClick = { /* ... */ },
        colors = ButtonDefaults.buttonColors(
            // PROBLEM: Hardcoded purple!
            // This stays purple even in dark mode
            containerColor = Color(0xFF6200EE)
        )
    ) {
        Text("Click me")
    }
}

✅ GOOD: Using Material 3 Tokens

// ============================================================
// WHY THIS IS GOOD:
// 1. Automatic dark/light mode support
// 2. Follows Material Design guidelines
// 3. Good accessibility by default
// 4. Easy to theme
// ============================================================
 
@Composable
fun GoodButton() {
    Button(
        onClick = { /* ... */ },
        // SOLUTION: Use semantic tokens!
        // MaterialTheme.colorScheme.primary adapts automatically
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.primary  // ← Magic!
        )
    ) {
        Text("Click me")
    }
}

Implementing Dynamic Colors (Android 12+)

What Are Dynamic Colors?

Dynamic colors (Material You) extract colors from the user's wallpaper. This makes each phone unique!

// ============================================================
// DYNAMIC COLORS EXPLAINED:
// On Android 12+:
// - User picks a wallpaper
// - Android extracts a color palette
// - Your app uses those colors automatically!
// 
// This is optional but recommended
// ============================================================
 
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),  // ← Auto-detect system setting
    dynamicColor: Boolean = true,                  // ← Enable dynamic colors
    content: @Composable () -> Unit
) {
    // ============================================================
    // Step 1: Choose the right color scheme
    // ============================================================
    val colorScheme = when {
        // Case 1: Android 12+ (API 31+) AND dynamic colors enabled
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            // Get the context to extract colors
            val context = LocalContext.current
            
            // Choose dark or light based on system
            if (darkTheme) {
                // dynamicDarkColorScheme extracts colors optimized for dark mode
                dynamicDarkColorScheme(context)
            } else {
                // dynamicLightColorScheme extracts colors optimized for light mode
                dynamicLightColorScheme(context)
            }
        }
        
        // Case 2: Dark theme (fallback for older Android)
        darkTheme -> DarkColorScheme
        
        // Case 3: Light theme (fallback for older Android)
        else -> LightColorScheme
    }
 
    // ============================================================
    // Step 2: Apply the theme
    // ============================================================
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography(),
        content = content
    )
}

Creating Custom Color Schemes

From Seed Color

You can generate a complete color scheme from a single color:

// ============================================================
// FROM SEED COLOR - Generate entire scheme from one color!
// This is super useful for brand colors
// ============================================================
 
@Composable
fun BrandTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    // Your brand color - this becomes the primary
    val seedColor = Color(0xFF6750A4)  // ← Change this to your brand color!
    
    // ============================================================
    // ColorScheme.from() generates all colors from the seed
    // It creates primary, secondary, tertiary, surfaces, etc.
    // ============================================================
    val colorScheme = when {
        // Dynamic colors on Android 12+
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        
        // Custom scheme from seed (fallback)
        darkTheme -> ColorScheme.from(
            color = seedColor,
            brightness = Brightness.Dark  // ← Generate dark variant
        )
        
        // Custom scheme from seed (light)
        else -> ColorScheme.from(
            color = seedColor,
            brightness = Brightness.Light  // ← Generate light variant
        )
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography(),
        content = content
    )
}

Creating a Custom Theme (Complete Example)

Here's a production-ready custom theme:

// ============================================================
// CUSTOM THEME - Your own branding!
// ============================================================
 
// Define your brand colors
object BrandColors {
    // Primary brand color - used for main actions
    val Purple40 = Color(0xFF6750A4)
    val PurpleGrey40 = Color(0xFF625B71)
    val Pink40 = Color(0xFF7D5260)
    
    // Dark variants
    val Purple80 = Color(0xFFD0BCFF)
    val PurpleGrey80 = Color(0xFFCCC2DC)
    val Pink80 = Color(0xFFEFB8C8)
}
 
@Composable
fun ClipVaultTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    // ============================================================
    // Step 1: Define color schemes for light and dark
    // ============================================================
    val lightColorScheme = ColorScheme(
        // Primary - Main brand color
        primary = BrandColors.Purple40,
        onPrimary = Color.White,
        primaryContainer = BrandColors.Purple80,
        onPrimaryContainer = Color(0xFF21005D),
        
        // Secondary
        secondary = BrandColors.PurpleGrey40,
        onSecondary = Color.White,
        secondaryContainer = BrandColors.PurpleGrey80,
        onSecondaryContainer = Color(0xFF1D192B),
        
        // Tertiary
        tertiary = BrandColors.Pink40,
        onTertiary = Color.White,
        tertiaryContainer = BrandColors.Pink80,
        onTertiaryContainer = Color(0xFF31111D),
        
        // Background & Surface
        background = Color(0xFFFFFBFE),
        onBackground = Color(0xFF1C1B1F),
        surface = Color(0xFFFFFBFE),
        onSurface = Color(0xFF1C1B1F),
        
        // Error
        error = Color(0xFFB3261E),
        onError = Color.White
    )
    
    val darkColorScheme = ColorScheme(
        // Primary - Dark variant
        primary = BrandColors.Purple80,
        onPrimary = Color(0xFF381E72),
        primaryContainer = BrandColors.Purple40,
        onPrimaryContainer = BrandColors.Purple80,
        
        // Secondary
        secondary = BrandColors.PurpleGrey80,
        onSecondary = Color(0xFF332D41),
        secondaryContainer = BrandColors.PurpleGrey40,
        onSecondaryContainer = BrandColors.PurpleGrey80,
        
        // Tertiary
        tertiary = BrandColors.Pink80,
        onTertiary = Color(0xFF492532),
        tertiaryContainer = BrandColors.Pink40,
        onTertiaryContainer = BrandColors.Pink80,
        
        // Background & Surface
        background = Color(0xFF1C1B1F),
        onBackground = Color(0xFFE6E1E5),
        surface = Color(0xFF1C1B1F),
        onSurface = Color(0xFFE6E1E5),
        
        // Error
        error = Color(0xFFF2B8B5),
        onError = Color(0xFF601410)
    )
    
    // ============================================================
    // Step 2: Choose which scheme to use
    // ============================================================
    val colorScheme = when {
        // Android 12+ with dynamic colors
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        
        // Custom dark/light schemes
        darkTheme -> darkColorScheme
        else -> lightColorScheme
    }
    
    // ============================================================
    // Step 3: Apply Typography
    // ============================================================
    val typography = Typography(
        // Display styles - for large text
        displayLarge = TextStyle(
            fontFamily = FontFamily.Default,
            fontWeight = FontWeight.Normal,
            fontSize = 57.sp,
            lineHeight = 64.sp,
            letterSpacing = (-0.25).sp
        ),
        
        // Headline styles - for section headers
        headlineLarge = TextStyle(
            fontFamily = FontFamily.Default,
            fontWeight = FontWeight.Normal,
            fontSize = 32.sp,
            lineHeight = 40.sp,
            letterSpacing = 0.sp
        ),
        
        // Title styles - for card titles
        titleLarge = TextStyle(
            fontFamily = FontFamily.Default,
            fontWeight = FontWeight.Normal,
            fontSize = 22.sp,
            lineHeight = 28.sp,
            letterSpacing = 0.sp
        ),
        
        // Body styles - for regular text
        bodyLarge = TextStyle(
            fontFamily = FontFamily.Default,
            fontWeight = FontWeight.Normal,
            fontSize = 16.sp,
            lineHeight = 24.sp,
            letterSpacing = 0.5.sp
        ),
        
        // Label styles - for buttons, captions
        labelLarge = TextStyle(
            fontFamily = FontFamily.Default,
            fontWeight = FontWeight.Medium,
            fontSize = 14.sp,
            lineHeight = 20.sp,
            letterSpacing = 0.1.sp
        )
    )
    
    // ============================================================
    // Step 4: Apply the theme to your app
    // ============================================================
    MaterialTheme(
        colorScheme = colorScheme,
        typography = typography,
        content = content
    )
}

Using Your Custom Theme

// ============================================================
// USING THE THEME IN YOUR APP
// ============================================================
 
@Composable
fun MainApp() {
    // Wrap everything in your theme
    ClipVaultTheme {
        // Now all Material 3 components use your colors!
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("ClipVault") },
                    colors = TopAppBarDefaults.topAppBarColors(
                        // These colors come from your theme!
                        containerColor = MaterialTheme.colorScheme.primaryContainer,
                        titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
                    )
                )
            }
        ) { padding ->
            Column(
                modifier = Modifier.padding(padding)
            ) {
                // Primary button - uses your brand color!
                Button(
                    onClick = { }
                ) {
                    Text("Primary")
                }
                
                // Secondary button
                OutlinedButton(
                    onClick = { }
                ) {
                    Text("Secondary")
                }
                
                // Text button
                TextButton(
                    onClick = { }
                ) {
                    Text("Text Button")
                }
                
                // Card with your surface colors
                Card(
                    colors = CardDefaults.cardColors(
                        containerColor = MaterialTheme.colorScheme.surfaceVariant
                    )
                ) {
                    Text(
                        "Card with variant surface color",
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
    }
}

Typography in Material 3

Understanding Typography Tokens

// ============================================================
// MATERIAL 3 TYPOGRAPHY SYSTEM
// ============================================================
 
val Typography = Typography(
    // DISPLAY - Largest text, for very prominent headings
    displayLarge = TextStyle(fontSize = 57.sp),
    displayMedium = TextStyle(fontSize = 45.sp),
    displaySmall = TextStyle(fontSize = 36.sp),
    
    // HEADLINE - Section headers
    headlineLarge = TextStyle(fontSize = 32.sp),
    headlineMedium = TextStyle(fontSize = 28.sp),
    headlineSmall = TextStyle(fontSize = 24.sp),
    
    // TITLE - Card titles, list headers
    titleLarge = TextStyle(fontSize = 22.sp),
    titleMedium = TextStyle(fontSize = 16.sp, letterSpacing = 0.15.sp),
    titleSmall = TextStyle(fontSize = 14.sp, letterSpacing = 0.1.sp),
    
    // BODY - Regular text content
    bodyLarge = TextStyle(fontSize = 16.sp, letterSpacing = 0.5.sp),
    bodyMedium = TextStyle(fontSize = 14.sp, letterSpacing = 0.25.sp),
    bodySmall = TextStyle(fontSize = 12.sp, letterSpacing = 0.4.sp),
    
    // LABEL - Buttons, captions
    labelLarge = TextStyle(fontSize = 14.sp, letterSpacing = 0.1.sp),
    labelMedium = TextStyle(fontSize = 12.sp, letterSpacing = 0.5.sp),
    labelSmall = TextStyle(fontSize = 11.sp, letterSpacing = 0.5.sp)
)

Color Tokens Reference

Material 3 provides many color tokens. Here's what each does:

Primary Colors

These are your main brand colors:

  • primary → Main actions (buttons, links)

    • Light: Purple 40
    • Dark: Purple 80
  • onPrimary → Text/icons ON TOP OF primary color

    • Light: White
    • Dark: Dark Purple
  • primaryContainer → Cards, surfaces that need subtle emphasis

    • Light: Purple 80
    • Dark: Purple 40
  • onPrimaryContainer → Text ON TOP OF primary container

    • Light: Dark Purple
    • Dark: Purple 80

Surface Colors

These control backgrounds and text:

  • surface → Main background color

    • Light: White
    • Dark: Dark Gray
  • onSurface → Main text color

    • Light: Dark Gray
    • Dark: White
  • surfaceVariant → Secondary surfaces (cards, dialogs)

    • Light: Light Gray
    • Dark: Dark Gray
  • onSurfaceVariant → Secondary text

    • Light: Gray
    • Dark: Light Gray

Secondary & Tertiary

Use these for accents and variety:

  • secondary → Secondary actions, filters, tags
  • tertiary → Highlights, accents, decorative elements

Quick Decision Guide

🎯 Which colors to use?

  • Primary → Main actions, buttons, links
  • Secondary → Secondary actions, filters
  • Tertiary → Accents, highlights
  • Surface → Backgrounds, cards
  • Error → Error states, destructive actions

🎯 Dynamic colors?

  • Enable by default → Better user experience on Android 12+
  • Provide fallback → Always have light/dark schemes ready
  • Brand consistency → Use seed color for custom themes

Common Mistakes

❌ Don't Hardcode Colors

// BAD
Text(color = Color(0xFF6200EE))
 
// GOOD
Text(color = MaterialTheme.colorScheme.primary)

❌ Don't Forget Dark Mode

// BAD
Background(color = Color.White)
 
// GOOD
Background(color = MaterialTheme.colorScheme.surface)

❌ Don't Skip Typography

// BAD
Text(fontSize = 20.sp)
 
// GOOD
Text(style = MaterialTheme.typography.titleLarge)

Key Takeaway

Always use Material 3 color tokens (MaterialTheme.colorScheme.primary, etc.) instead of hardcoded colors. Enable dynamic colors on Android 12+ for a personalized experience, but always provide fallback color schemes. Create a custom theme by defining your brand colors in ColorScheme - this ensures consistency across light/dark modes and makes future changes easy!