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.
- Your First Screen
- State Management
- Navigation
- Lists
- Theming (this post)
- Forms
- Side Effects
- Custom Layouts
- API Integration
- 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
| Role | Use for |
|---|---|
primary | Main brand color, key buttons, active states |
onPrimary | Text/icons on primary color |
primaryContainer | Subtle brand color for cards, chips |
onPrimaryContainer | Text/icons on primary container |
secondary | Less prominent UI elements |
surface | Card backgrounds, sheets |
onSurface | Text on surface |
background | Screen background |
error | Error 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
| Style | Use for |
|---|---|
displayLarge/Medium/Small | Hero text, large numbers |
headlineLarge/Medium/Small | Section headings |
titleLarge/Medium/Small | Card titles, toolbar |
bodyLarge/Medium/Small | Body text, descriptions |
labelLarge/Medium/Small | Buttons, 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.