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.
@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
1. How does MaterialTheme make theme values available to all descendant composables?
2. What is the primary advantage of M3's semantic color roles (primary/onPrimary, etc.) over hardcoded colors?
3. What is required to support dynamic color reliably across all supported Android versions?
4. What happens if you nest a second MaterialTheme composable around a sub-tree with a different colorScheme?
5. Why is hardcoding Color(0xFF...) directly in composables discouraged?
Was this page helpful?
You May Also Like
Composable Functions Basics
Composable functions are the fundamental building block of Jetpack Compose UI — Kotlin functions marked @Composable that emit UI elements and can be freely composed together.
Modifiers in Compose
Modifiers are immutable, chainable objects that decorate or configure a composable's layout, appearance, and behavior — Compose's answer to layout params, styling, and gesture handling.
Text and Typography
The Text composable and Material 3's type scale let you render and style text consistently — controlling font, weight, size, color, and overflow — across an entire Compose app.
Layouts: Row, Column, and Box
Row, Column, and Box are Compose's three foundational layout composables for arranging children horizontally, vertically, or stacked, forming the basis of nearly every screen.