blog / compose · design
Material 3 Theming in Jetpack Compose: Complete Guide
Material 3 theming in Jetpack Compose comes down to one composable, MaterialTheme, and three objects you pass to it: a ColorScheme, a Typography and a Shapes. Get those right and every Material component in the app picks them up automatically. This guide shows how to build each one, add dynamic color and dark theme correctly, extend the theme with your own tokens, and avoid the mistakes that make a themed app look half-themed.
How do I set up a Material 3 theme in Compose?
Add the Material 3 library through the Compose BOM:
app/build.gradle.kts
dependencies {
// Check developer.android.com/develop/ui/compose/bom for the latest BOM
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.material3:material3")
}
BOM 2026.09.00 maps to the stable material3 1.4 line as of November 2026; check the BOM mapping page for the current version. Then write one theme function for the whole app. This is the shape Android Studio’s templates generate, and it is the right one:
ui/theme/Theme.kt
@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 -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Call it once, at the top of setContent, and nowhere else unless you deliberately want a sub-tree with a different theme:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AppTheme {
AppNavHost()
}
}
}
}
Inside the tree, read theme values with MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes. Never hardcode a Color(0xFF...) in a screen; once you do, dark theme and dynamic color stop working for that element.
What are the Material 3 color roles?
A ColorScheme is not a palette of brand colors; it is a set of roles. Each role says where a color is used, and each container color has a matching “on” color guaranteed to be readable on top of it. The roles you will use most:
| Role | Use it for | Text/icons on top |
|---|---|---|
primary | Main actions: filled buttons, FAB, active states | onPrimary |
primaryContainer | Lower-emphasis fills: selected cards, tonal buttons | onPrimaryContainer |
secondary / secondaryContainer | Less prominent components: filter chips, toggles | onSecondary / onSecondaryContainer |
tertiary / tertiaryContainer | Contrasting accents to balance primary and secondary | onTertiary / onTertiaryContainer |
error / errorContainer | Errors and destructive actions | onError / onErrorContainer |
surface | Default background of screens and sheets | onSurface, onSurfaceVariant |
surfaceContainerLowest … surfaceContainerHighest | Layered containers: cards, nav bars, dialogs | onSurface |
outline / outlineVariant | Borders and dividers | n/a |
There are also surfaceDim and surfaceBright, the inverse roles (inverseSurface, inverseOnSurface, inversePrimary) for snackbars, and the “fixed” roles (primaryFixed, onPrimaryFixed and friends) that keep the same tone in light and dark themes. The five surface container roles replaced the old habit of tinting surfaces by elevation: pick the container level that matches how far forward a surface sits, and the components already do this for you.
Always pair a container with its “on” role: primaryContainer with onPrimaryContainer, surface with onSurface. Mixing roles from different pairs is the fastest way to ship unreadable text.
How do I generate a color scheme from my brand color?
Do not hand-pick 30+ colors. Use the Material Theme Builder: enter one or more source colors, and it exports a Color.kt and Theme.kt with every role filled for light and dark, plus medium- and high-contrast variants. The output plugs into lightColorScheme() and darkColorScheme():
ui/theme/Color.kt
private val LightColors = lightColorScheme(
primary = Color(0xFF3B6939),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFBCF0B4),
onPrimaryContainer = Color(0xFF002204),
// ...every other role from the export
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFA1D39A),
onPrimary = Color(0xFF0A390F),
primaryContainer = Color(0xFF235024),
onPrimaryContainer = Color(0xFFBCF0B4),
// ...
)
Both builders take every role as an optional parameter with a baseline default, so a role you forget silently falls back to Material’s default purple. If something in your app looks unexpectedly purple, a role is missing.
How does dynamic color work, and should I use it?
Dynamic color (the core of Material You) derives a full color scheme from the user’s wallpaper. It is available on Android 12 (API 31) and higher, which is why the theme above checks Build.VERSION_CODES.S before calling dynamicLightColorScheme(context) or dynamicDarkColorScheme(context). On older devices you must fall back to your own scheme.
Should you turn it on? It depends on how much your brand lives in color:
- Utilities, tools, productivity apps: yes. Users like apps that match their phone, and the generated schemes are designed to meet contrast requirements.
- Brand-led apps (banking, retail, media): usually no, or make it a user setting. Your primary color is part of your identity.
- Middle ground: keep dynamic color for surfaces but override a few roles with brand values via
copy().
val scheme = dynamicLightColorScheme(context).copy(
primary = BrandGreen,
onPrimary = Color.White
)
If you offer dynamic color as a setting, store the choice (and the light/dark/system choice) somewhere observable such as DataStore, collect it in the Activity, and pass it into AppTheme. See Room vs DataStore for the storage side.
How do I support dark theme properly?
isSystemInDarkTheme() reads the system setting, and the theme swaps schemes. Most of the work is not in the theme but in making sure nothing bypasses it:
- Use
SurfaceorScaffoldat the root of each screen. They set the background tosurfaceand provideonSurfaceas the content color, soTextandIconinside pick the right color without being told. - Do not set
color = Color.Blackon text. Leave color unspecified to inherit the content color, or use a role. - Use
contentColorFor(backgroundColor)when you draw your own colored container and need the matching “on” color. - Check images and illustrations. A PNG with a white background looks broken in dark theme. Use vector drawables that reference theme colors, or provide night variants.
- Match the window background. Before Compose draws its first frame, the window shows your XML theme’s background. Use a
DayNightparent theme inthemes.xmlso dark-mode users do not get a white flash on launch.
To add an in-app override (light, dark, follow system), compute darkTheme from the stored preference and pass it in instead of relying on the default argument. With edge-to-edge, also make sure system bar icons follow the theme rather than the system setting; the edge-to-edge guide covers that.
How do I customize typography in Material 3?
The Material 3 type scale has 15 styles: display, headline, title, body and label, each in Large, Medium and Small. Components map to specific slots; for example, Text defaults to bodyLarge and buttons use labelLarge. Customize the slots rather than inventing new styles:
ui/theme/Type.kt
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold)
)
private val base = Typography()
val AppTypography = Typography(
displayLarge = base.displayLarge.copy(fontFamily = Inter),
headlineMedium = base.headlineMedium.copy(fontFamily = Inter),
titleLarge = base.titleLarge.copy(
fontFamily = Inter,
fontWeight = FontWeight.SemiBold
),
bodyLarge = base.bodyLarge.copy(fontFamily = Inter),
bodyMedium = base.bodyMedium.copy(fontFamily = Inter),
labelLarge = base.labelLarge.copy(
fontFamily = Inter,
fontWeight = FontWeight.Medium
)
// Repeat for the slots you use
)
Unlike the Material 2 version, the M3 Typography class has no single default font family parameter, so you set fontFamily per style. Copying from Typography() keeps Material’s sizes and line heights, which is what you want unless your designer has a full type scale of their own. Always size text in sp so it respects the user’s font-size setting, and test at the largest setting.
How do Material 3 shapes work?
Shapes has five sizes, from extraSmall to extraLarge. Components map to them: text fields and menus use small corners, cards use medium, sheets and dialogs use large and extra-large.
ui/theme/Shape.kt
val AppShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp)
)
Changing the scale is the cheapest way to give an app a distinct personality. Rounder corners feel friendlier; tighter corners feel more serious. Override per component only when one component genuinely needs to differ, using its shape parameter.
How do I add custom design tokens to MaterialTheme?
Real apps need values Material does not define: a “success” green, a “warning” amber, spacing steps, a brand gradient. For a value that is the same in every theme, an extension property is enough:
val Shapes.pill: Shape
get() = RoundedCornerShape(percent = 50)
For anything that varies by theme, such as colors that differ between light and dark, wrap MaterialTheme and provide your tokens through a CompositionLocal. This is the pattern from the official custom design systems guide:
ui/theme/ExtendedColors.kt
@Immutable
data class ExtendedColors(
val success: Color,
val onSuccess: Color,
val warning: Color,
val onWarning: Color
)
val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(
success = Color.Unspecified,
onSuccess = Color.Unspecified,
warning = Color.Unspecified,
onWarning = Color.Unspecified
)
}
private val LightExtended = ExtendedColors(
success = Color(0xFF1B6D2F), onSuccess = Color.White,
warning = Color(0xFF7A5900), onWarning = Color.White
)
private val DarkExtended = ExtendedColors(
success = Color(0xFF8FD89A), onSuccess = Color(0xFF00390F),
warning = Color(0xFFF5BF48), onWarning = Color(0xFF402D00)
)
object AppTheme {
val extendedColors: ExtendedColors
@Composable
get() = LocalExtendedColors.current
}
Then provide it inside your theme function, around MaterialTheme:
val extended = if (darkTheme) DarkExtended else LightExtended
CompositionLocalProvider(LocalExtendedColors provides extended) {
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Usage reads like the built-in theme: AppTheme.extendedColors.success. Kotlin allows the object and the AppTheme composable function to share a name, which is how MaterialTheme itself works. Use staticCompositionLocalOf because these values almost never change at runtime; when they do (switching theme), recomposing the whole tree is expected anyway. Keep the token set small; if you find yourself adding a fourth custom color role, check whether tertiary or a container role already covers it.
Common Material 3 theming mistakes
- Hardcoded colors in screens. Search the codebase for
Color(0xoutside the theme package. Each hit is a dark-mode bug waiting to happen. - Using
backgroundinstead ofsurface. M3 components are built around the surface roles, andbackgroundmostly exists for compatibility. Write new code againstsurfaceand the surface container roles. - Missing roles in a hand-written scheme. Unset roles fall back to the baseline purple. Export from Theme Builder instead.
- Theming inside every screen. One
AppThemeat the root. Nested themes are for deliberate exceptions, not habit. - Forgetting previews. Wrap previews in
AppThemeand adduiMode = Configuration.UI_MODE_NIGHT_YESto a second@Previewso you see both themes while you work. - Mixed Compose and View screens with two different looks. XML themes do not flow into Compose. If you still have View screens, keep a
Theme.Material3.DayNighttheme in XML with matching colors; the Compose vs XML guide covers interop.
Material 3 Expressive, the newer extension of M3 with a motion scheme and more shape and type options, is arriving through the material3 1.5 releases, including a MaterialExpressiveTheme entry point. Everything in this guide still applies: the color, type and shape systems underneath are the same. Check the Material 3 release notes for which Expressive APIs are stable in the version you use.
Frequently asked questions
What is the difference between MaterialTheme in Material 2 and Material 3?
They are different classes in different packages. Material 3 uses androidx.compose.material3.MaterialTheme with a ColorScheme of roles such as primaryContainer and surfaceContainer, a 15-style type scale and a five-size shape scale. Do not mix imports from androidx.compose.material and material3 in the same screen.
Does dynamic color work on Android 11 and lower?
No. dynamicLightColorScheme and dynamicDarkColorScheme need Android 12 (API 31) or higher. Check the SDK version and fall back to your own light and dark schemes on older devices.
How do I let users choose light or dark mode inside the app?
Store the choice (light, dark or follow system) in DataStore, collect it as state in your Activity, and pass the resulting Boolean into your theme function instead of using isSystemInDarkTheme() directly. The whole tree recomposes with the new scheme.
How do I use a custom font with Material 3 in Compose?
Put the font files in res/font, build a FontFamily from them, and set fontFamily on each TextStyle in your Typography. Material 3 Typography has no single default font family parameter, so copying from Typography() and overriding fontFamily per style is the simplest route.
Can I use Material 3 components with my own design system?
Yes. Either extend MaterialTheme with your own tokens through a CompositionLocal, or replace parts of it. Material components read from MaterialTheme, so wrap them in your own composables when they need values from your custom tokens.