Material Design 3, Theming, Dark Mode & Dynamic Color
Master Material You — from colorScheme to wallpaper-extracted dynamic color and custom theming architecture
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Material Design 3 (M3, Material You) is Google's design system for Android apps. In Jetpack Compose it is implemented via MaterialTheme, which provides a ColorScheme, Typography, and Shapes to all composables in its tree. Dynamic color (Android 12+) extracts a personalized palette from the user's wallpaper. Dark mode is supported by swapping ColorScheme objects based on system preference.
Real-world relevance
Google's own apps — Google Messages, Google Photos, Google Calendar — were the first to adopt Material You with dynamic color at Android 12's launch. Monzo Bank uses a custom color system layered over M3, with carefully tuned semantic color roles that map to their coral brand color. YouTube's dark mode uses M3's surface and surfaceVariant roles to create depth hierarchy without using elevation shadows.
Key points
- Material Design 3 — what changed from M2 — Material 3 (Material You) introduced dynamic color from wallpaper, updated color roles (primary, secondary, tertiary, surface, error — each with container and on- variants), refined typography scale (Display, Headline, Title, Body, Label), and new components (FilledTonalButton, NavigationBar, SearchBar, DatePicker, BottomSheet).
- MaterialTheme in Compose — three slots — MaterialTheme provides three CompositionLocals: colorScheme (ColorScheme), typography (Typography), and shapes (Shapes). Composables read them via MaterialTheme.colorScheme.primary, MaterialTheme.typography.titleMedium, MaterialTheme.shapes.medium. Never hardcode Color(0xFF...) — always reference a color role.
- ColorScheme — the 30 color roles — M3 defines 30 color roles: primary, onPrimary, primaryContainer, onPrimaryContainer, secondary, onSecondary, secondaryContainer, onSecondaryContainer, tertiary, error, background, surface, surfaceVariant, outline, and more. Each role has a defined contrast relationship. Use them semantically: primary for key actions, secondary for supporting actions, error for destructive states.
- Dark mode — two ColorScheme objects — Create a lightColorScheme() and a darkColorScheme() using M3 color roles. Switch between them based on isSystemInDarkTheme() in Compose. Persist the user's override in DataStore or SharedPreferences. Never use Color.White / Color.Black directly — use surface/onSurface roles instead.
- Dynamic color — Android 12+ (Material You) — On Android 12+, extract a ColorScheme from the user's wallpaper using dynamicLightColorScheme(context) / dynamicDarkColorScheme(context). This gives each user a personalized theme. Check with Build.VERSION.SDK_INT >= Build.VERSION_CODES.S before calling these APIs. Fall back to a static brand scheme on older versions.
- Typography — M3 type scale — M3 defines 15 text styles across 5 groups: Display (3), Headline (3), Title (3), Body (3), Label (3). Create a Typography() object with custom TextStyle for each slot. Font families, weights, sizes, and tracking are all configurable. Use typography roles semantically: DisplayLarge for hero text, bodyMedium for reading content, labelSmall for captions.
- Shapes — the expressiveness lever — M3 shapes replace the single cornerRadius with an ExtraSmall to ExtraLarge scale. Shapes.medium (12dp) is the default for Cards and Dialogs. Custom shapes can use CutCornerShape or RoundedCornerShape. Shape communicates brand personality — more rounded = friendly, more angular = professional.
- Custom theming architecture — For design systems with brand colors that don't map to M3 roles, create a custom CompositionLocal (e.g., LocalAppColors) alongside MaterialTheme. This lets your brand colors be accessible throughout the Compose tree without polluting M3's semantic roles. Spotify, Airbnb, and Uber all maintain custom design systems layered over or alongside M3.
- Status bar and navigation bar theming — Use WindowCompat.setDecorFitsSystemWindows(window, false) for edge-to-edge. Set status bar and navigation bar icon colors with WindowInsetsControllerCompat — isAppearanceLightStatusBars = !isDark. In Compose, accompanist-systemuicontroller is legacy; use the Activity's window directly or the native WindowInsetsController API.
- M3 components — FilledButton, Card, TopAppBar — M3 Compose components (Button, OutlinedButton, FilledTonalButton, ElevatedButton) handle state (pressed, focused, hovered, dragged) and color automatically using the colorScheme. Do not wrap them in custom containers that override their internal colors — you break the M3 state layer system.
- Previewing themes in Android Studio — Use @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) for dark mode preview. Wrap preview composables in your app's theme function. Multiple previews with different uiMode and fontScale catch contrast and text scaling issues without running the app. Use PreviewParameterProvider for data-driven previews.
- Color token vs color value — the key discipline — Never commit a raw Color(0xFF_D9FE06) to production UI code. Define it as a named token in your color palette file (e.g., val Lime500 = Color(0xFF_D9FE06)) and reference it only in your theme (colorScheme.primary = Lime500). UI code references the role (primary), never the value. This makes theme switching and A/B testing possible.
Code example
// ── App theme with dynamic color and dark mode support ───────
@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(
primary = Color(0xFF_D9FE06),
onPrimary = Color(0xFF_12151A),
secondary = Color(0xFF_A8C7FA),
background = Color(0xFF_12151A),
surface = Color(0xFF_1D1F25)
)
else -> lightColorScheme(
primary = Color(0xFF_4A5900),
onPrimary = Color(0xFF_FFFFFF),
secondary = Color(0xFF_555F71),
background = Color(0xFF_FAFCF5),
surface = Color(0xFF_FFFFFF)
)
}
val typography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily(Font(R.font.poppins_semibold)),
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily(Font(R.font.poppins_regular)),
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
)
)
MaterialTheme(
colorScheme = colorScheme,
typography = typography,
shapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp)
),
content = content
)
}Line-by-line walkthrough
- 1. AppTheme accepts darkTheme and dynamicColor parameters — composable callers can override them for preview, testing, or user preference
- 2. The outermost when block checks for dynamic color availability: SDK >= S (Android 12) is required — older devices fall back to static brand schemes
- 3. dynamicDarkColorScheme(context) and dynamicLightColorScheme(context) call into the system wallpaper engine — they extract five seed colors and generate a full 30-role scheme
- 4. darkColorScheme() and lightColorScheme() are named constructors that set M3 role defaults — you only override the roles relevant to your brand, the rest default to M3 system values
- 5. Color(0xFF_D9FE06) is a raw color token — it is used only inside the theme block, never in UI composables directly
- 6. Typography() lets you override any of the 15 type scale slots — unspecified slots inherit M3 defaults, so you only define what differs from the baseline
- 7. FontFamily(Font(R.font.poppins_semibold)) loads a bundled font resource — the downloadable fonts API (GoogleFont) avoids bundling by fetching from Google Fonts at runtime
- 8. Shapes() takes small, medium, and large — M3 maps them to specific component groups; Cards use medium, FABs use large, Chips use small
- 9. MaterialTheme wraps content and provides colorScheme, typography, and shapes as CompositionLocals — every child composable in content can read them via MaterialTheme.colorScheme etc.
Spot the bug
@Composable
fun StatusBadge(isOnline: Boolean) {
val badgeColor = if (isOnline) Color(0xFF4CAF50) else Color(0xFFE53935)
Box(
modifier = Modifier
.background(badgeColor)
.padding(4.dp)
) {
Text(
text = if (isOnline) "Online" else "Offline",
color = Color.White
)
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Material Design 3 — Official (material.io)
- Material Design 3 in Compose (Android Developers)
- Dynamic color — Material You (Android Developers)
- Dark theme — Android Guide (Android Developers)
- M3 Theme Builder (material.io)