PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 2 min read

Theming in Compose — Material3, Dynamic Color & Dark Mode

Set up a complete theme system in Jetpack Compose — Material3 color schemes, typography, dynamic color, dark mode, and custom theme extensions.


This is Part 5 of a 10-part series on Jetpack Compose.

  1. Your First Screen
  2. State Management
  3. Navigation
  4. Lists
  5. Theming (this post)
  6. Forms
  7. Side Effects
  8. Custom Layouts
  9. API Integration
  10. Migration from XML

Material3 theme structure

Material3 (Material You) has three pillars: color, typography, and shapes. MaterialTheme wraps your app and provides these through composition locals.

MaterialTheme(
    colorScheme = myColorScheme,
    typography = myTypography,
    shapes = myShapes
) {
    // Your app content
}

Anywhere inside, access theme values:

Text(
    text = "Hello",
    color = MaterialTheme.colorScheme.primary,
    style = MaterialTheme.typography.headlineMedium
)

Color scheme

Material3 uses a structured color system with roles, not individual colors:

import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color

// Define your brand colors
val Purple = Color(0xFF6200EA)
val PurpleLight = Color(0xFFBB86FC)
val PurpleDark = Color(0xFF3700B3)

val LightColorScheme = lightColorScheme(
    primary = Purple,
    onPrimary = Color.White,
    primaryContainer = Color(0xFFE8DEF8),
    onPrimaryContainer = Color(0xFF21005D),
    secondary = Color(0xFF625B71),
    onSecondary = Color.White,
    background = Color(0xFFFFFBFE),
    onBackground = Color(0xFF1C1B1F),
    surface = Color(0xFFFFFBFE),
    onSurface = Color(0xFF1C1B1F),
    error = Color(0xFFB3261E),
    onError = Color.White
)

val DarkColorScheme = darkColorScheme(
    primary = PurpleLight,
    onPrimary = Color(0xFF381E72),
    primaryContainer = Color(0xFF4F378B),
    onPrimaryContainer = Color(0xFFEADDFF),
    secondary = Color(0xFFCCC2DC),
    onSecondary = Color(0xFF332D41),
    background = Color(0xFF1C1B1F),
    onBackground = Color(0xFFE6E1E5),
    surface = Color(0xFF1C1B1F),
    onSurface = Color(0xFFE6E1E5),
    error = Color(0xFFF2B8B5),
    onError = Color(0xFF601410)
)

Color roles explained

RoleUse for
primaryMain brand color, key buttons, active states
onPrimaryText/icons on primary color
primaryContainerSubtle brand color for cards, chips
onPrimaryContainerText/icons on primary container
secondaryLess prominent UI elements
surfaceCard backgrounds, sheets
onSurfaceText on surface
backgroundScreen background
errorError states

The on prefix means “content on top of that color.” onPrimary is the text color on a primary-colored button.

Material Theme Builder

Don’t hand-pick every color. Use Material Theme Builder — enter your brand color and it generates the full color scheme for both light and dark.

Dynamic Color (Android 12+)

Dynamic color derives your theme from the user’s wallpaper:

import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import android.os.Build
import androidx.compose.ui.platform.LocalContext

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

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

On Android 12+, the app adapts to the user’s wallpaper colors. On older devices, it falls back to your custom color scheme.

Dark mode

System-level detection

import androidx.compose.foundation.isSystemInDarkTheme

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    MaterialTheme(colorScheme = colorScheme, content = content)
}

User preference toggle

@Composable
fun AppTheme(
    themeMode: ThemeMode = ThemeMode.SYSTEM,
    content: @Composable () -> Unit
) {
    val darkTheme = when (themeMode) {
        ThemeMode.LIGHT -> false
        ThemeMode.DARK -> true
        ThemeMode.SYSTEM -> isSystemInDarkTheme()
    }

    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    MaterialTheme(colorScheme = colorScheme, content = content)
}

enum class ThemeMode { LIGHT, DARK, SYSTEM }

Store the preference in DataStore or SharedPreferences. Pass it down from the Activity:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val themeMode by settingsViewModel.themeMode.collectAsStateWithLifecycle()
            AppTheme(themeMode = themeMode) {
                AppNavigation()
            }
        }
    }
}

Typography

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val InterFontFamily = FontFamily(
    Font(R.font.inter_regular, FontWeight.Normal),
    Font(R.font.inter_medium, FontWeight.Medium),
    Font(R.font.inter_semibold, FontWeight.SemiBold),
    Font(R.font.inter_bold, FontWeight.Bold)
)

val AppTypography = Typography(
    displayLarge = TextStyle(
        fontFamily = InterFontFamily,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp
    ),
    headlineMedium = TextStyle(
        fontFamily = InterFontFamily,
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp
    ),
    titleLarge = TextStyle(
        fontFamily = InterFontFamily,
        fontWeight = FontWeight.SemiBold,
        fontSize = 22.sp,
        lineHeight = 28.sp
    ),
    bodyLarge = TextStyle(
        fontFamily = InterFontFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp
    ),
    bodyMedium = TextStyle(
        fontFamily = InterFontFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 20.sp
    ),
    labelMedium = TextStyle(
        fontFamily = InterFontFamily,
        fontWeight = FontWeight.Medium,
        fontSize = 12.sp,
        lineHeight = 16.sp
    )
)

Material3 type scale

StyleUse for
displayLarge/Medium/SmallHero text, large numbers
headlineLarge/Medium/SmallSection headings
titleLarge/Medium/SmallCard titles, toolbar
bodyLarge/Medium/SmallBody text, descriptions
labelLarge/Medium/SmallButtons, chips, captions

Use MaterialTheme.typography.bodyMedium instead of hardcoding fontSize = 14.sp. This ensures consistency and makes global changes easy.

Shapes

import androidx.compose.material3.Shapes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp

val AppShapes = Shapes(
    extraSmall = RoundedCornerShape(4.dp),
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(16.dp),
    extraLarge = RoundedCornerShape(24.dp)
)

Material3 components use these shapes automatically. Buttons use small, cards use medium, bottom sheets use large.

Custom theme extensions

Sometimes you need colors beyond Material3’s role system. Use composition locals:

import androidx.compose.runtime.staticCompositionLocalOf

data class ExtendedColors(
    val success: Color,
    val onSuccess: Color,
    val warning: Color,
    val onWarning: Color,
    val info: Color,
    val onInfo: Color
)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        success = Color(0xFF4CAF50),
        onSuccess = Color.White,
        warning = Color(0xFFFF9800),
        onWarning = Color.White,
        info = Color(0xFF2196F3),
        onInfo = Color.White
    )
}

val LightExtendedColors = ExtendedColors(
    success = Color(0xFF4CAF50),
    onSuccess = Color.White,
    warning = Color(0xFFFF9800),
    onWarning = Color.White,
    info = Color(0xFF2196F3),
    onInfo = Color.White
)

val DarkExtendedColors = ExtendedColors(
    success = Color(0xFF81C784),
    onSuccess = Color(0xFF1B5E20),
    warning = Color(0xFFFFB74D),
    onWarning = Color(0xFF4E2600),
    info = Color(0xFF64B5F6),
    onInfo = Color(0xFF0D47A1)
)

Provide them in your theme:

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    val extendedColors = if (darkTheme) DarkExtendedColors else LightExtendedColors

    CompositionLocalProvider(
        LocalExtendedColors provides extendedColors
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = AppTypography,
            shapes = AppShapes,
            content = content
        )
    }
}

// Access anywhere
object ExtendedTheme {
    val colors: ExtendedColors
        @Composable
        get() = LocalExtendedColors.current
}

// Usage
Surface(color = ExtendedTheme.colors.success) {
    Text("Success!", color = ExtendedTheme.colors.onSuccess)
}

Putting it all together

Your theme file (ui/theme/Theme.kt):

@Composable
fun AppTheme(
    themeMode: ThemeMode = ThemeMode.SYSTEM,
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val darkTheme = when (themeMode) {
        ThemeMode.LIGHT -> false
        ThemeMode.DARK -> true
        ThemeMode.SYSTEM -> isSystemInDarkTheme()
    }

    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    val extendedColors = if (darkTheme) DarkExtendedColors else LightExtendedColors

    CompositionLocalProvider(
        LocalExtendedColors provides extendedColors
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = AppTypography,
            shapes = AppShapes,
            content = content
        )
    }
}

Use in your Activity:

setContent {
    AppTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            AppNavigation()
        }
    }
}

Common mistakes

Hardcoding colors

// Don't
Text(text = "Hello", color = Color(0xFF1C1B1F))

// Do
Text(text = "Hello", color = MaterialTheme.colorScheme.onBackground)

Hardcoded colors break in dark mode. Always use semantic color roles.

Ignoring the on-colors

// Wrong — white text might be invisible on a light primary container
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
    Text("Hello", color = Color.White)
}

// Right — use the matching on-color
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
    Text("Hello", color = MaterialTheme.colorScheme.onPrimaryContainer)
}

Not testing dark mode

Always preview both themes:

@Preview(name = "Light")
@Composable
fun CardPreviewLight() {
    AppTheme(themeMode = ThemeMode.LIGHT) { MyCard() }
}

@Preview(name = "Dark")
@Composable
fun CardPreviewDark() {
    AppTheme(themeMode = ThemeMode.DARK) { MyCard() }
}

Summary

  • Color scheme: Use semantic roles (primary, onSurface, etc.), not hardcoded colors
  • Dynamic color: Derives from wallpaper on Android 12+ with fallback
  • Dark mode: Detect system preference, support user toggle
  • Typography: Use Material3 type scale with custom fonts
  • Shapes: Configure once, used by all Material3 components
  • Extensions: CompositionLocals for custom colors beyond Material3

Next: Forms in Compose — TextField, Validation & Keyboard Actions.